Acegi Captcha způsob integrace a možnosti použití

V tomto příspěvku se nechci věnovat popisu zprovoznění jCaptchy v bezpečnostní frameworku Acegi Security, jelikož toto je velmi dobře popsáno již v existujícím článku na MoroSystems weblogu. Spíš se chci zaobírat způsobem, jakým se k integraci do Acegi frameworku autoři postavili. Tento způsob mi přijde totiž přinejmenším neobvyklý. Zachovává sice zavedené principy Acegi, ale ten neodpovídá mým (ale řekl bych vcelku přirozeným) představám o tom, jak by měla captcha ve web strákách fungovat.

Princip práce s captchou v Acegi je podobný principu standardního přihlašování. Acegi při přístupu na "chráněné url" kontroluje ověření uživatele v SecurityContextu a pokud uživatel není ověřen, přesměruje tok aplikace na přihlašovací formulář nebo v případě captchy na formulář obsahující obrázek a textové pole pro vepsání rozpoznané captchy. Pokud řešíme přihlašování, je tento způsob přirozený - v případě captchy však očekávám, že captcha bude rovnou součástí formuláře, který je "hlídán". Tak ale integrace jCaptchy v Acegi ve svém základu nefunguje.

Pokud Vám tento způsob připadne taky trochu podivný a zajímá Vás, jak si s tím poradit, čtěte dál.

Současná integrace Captchy do Acegi umožňuje vývojářům nezabývat se v jednotlivých formulářích captchou, ale ve chvíli kdy je již aplikace hotová určit URL, které mohou být zneužitelná boty a nastavit pravidla pro rozpoznávání chování robotů. Acegi v tomto směru poskytuje několik strategií:

Po krátké úvaze mě však přišel použitelný jediný a to je TestOnceAfterMaxRequestsCaptchaChannelProcessor s nastavením Threshold na hodnotu 0. To znamená, že při prvním přístupu na formulář chráněný captchou se má provést ověření "humanity" uživatele a po správném ověření už považovat uživatele s danou session za ověřeného a neobtěžovat ho dalšími captcha obrázky.

Captcha servlet filter

Vzhledem k nemožnosti cokoliv rozumného provést s existujícím filtrem org.acegisecurity.captcha.CaptchaValidationProcessingFilter, byl jsem nucen napsat si na základě části jeho funkcionality filtr vlastní (bohužel řada tříd z Acegi má jednu nepříjemnou vlastnost a to tu, že je velmi obtížné je extendovat či jinak modifikovat, jelikož mají řadu private metod / fieldů nebo občas i mnoho funkcionality v jedné metodě, kde by člověk potřeboval změnit jen jednu její část).

Nuže v následujícím kódu je implementace filtru, který provádí validaci captchy a zároveň poskytuje vygenerovaný captcha obrázek. Jelikož je filtr součástí Acegi delegating filtru je obvykle posazen na url-pattern "/*", což nám umožňuje jednoduše se navěsit na libovolné volání serveru v daném kontextu. Toho filtr využívá k tomu, aby mohl při dotazu na konkrétní url (např.: http://server/context/generatedCaptchaImage.jpg) online vygenerovat captcha obrázek a okamžitě jej vložit do response.

Ten stejný filtr provádí validaci jím vygenerovaných captcha obrázků v případě, že v parametrech requestu objeví parametr s názvem "captcha" (nebo kterýkoliv jiný, který uvedeme v konfiguraci), pokusí se zvalidovat jeho hodnotu vůči naposledy poskytnuté captche. V případě, že validace neprojde je do requestu uložen atribut signalizující neplatný pokus o validaci captchy - v opačném případě je do CaptchaSecurityContext nastaven příznak human na true.

/**
 * Filter for web integration of the {@link org.acegisecurity.captcha.CaptchaServiceProxy}. 
 * It basically intercept calls containing the specific validation parameter, use the {@link org.acegisecurity.captcha.CaptchaServiceProxy} to
 * validate the request, and update the {@link org.acegisecurity.captcha.CaptchaSecurityContext} if the request passed the validation. 
 * This Filter should be placed after the ContextIntegration filter and before the {@link
 * org.acegisecurity.captcha.CaptchaChannelProcessorTemplate} filter in the filter stack in order to update the {@link org.acegisecurity.captcha.CaptchaSecurityContext}
 * before the humanity verification routine occurs. 
 * This filter should only be used in conjunction with the {@link org.acegisecurity.captcha.CaptchaSecurityContext}
 *

 * Filter extends the original one with adding functionality to return binary image when url end with
 * particular string.
 */
public class CaptchaGenerationValidationProcessingFilter implements InitializingBean, Filter, ApplicationEventPublisherAware {
	private static Log log = LogFactory.getLog(CaptchaGenerationValidationProcessingFilter.class);
	private CaptchaServiceProxy captchaService;
	private String captchaImgUrl = "generatedCaptchaImage.jpg";
	private String captchaValidationParameter = "captcha";
	private CaptchaService jcaptchaService;
	private ApplicationEventPublisher publisher;
	public static final String REQUEST_CAPTCHA_RECOGNITION_FAILURE_FLAG = "_acegi_captcha_recognition_failure_flag";
	public String getCaptchaImgUrl() {
		return captchaImgUrl;
	}
	public void setCaptchaImgUrl(String captchaImgUrl) {
		this.captchaImgUrl = captchaImgUrl;
	}
	public void setCaptchaValidationParameter(String captchaValidationParameter) {
		this.captchaValidationParameter = captchaValidationParameter;
	}
	public String getCaptchaValidationParameter() {
		return captchaValidationParameter;
	}
	public void setCaptchaService(CaptchaServiceProxy captchaService) {
		this.captchaService = captchaService;
	}
	public void setCaptchaServiceProvider(CaptchaServiceProvider captchaServiceProvider) {
		this.jcaptchaService = captchaServiceProvider.getCaptchaService();
	}
	//~ Methods ========================================================================================================
	public void afterPropertiesSet() throws Exception {
		if(this.captchaService == null) {
			throw new IllegalArgumentException("CaptchaServiceProxy must be defined ");
		}
		if((this.captchaValidationParameter == null) || "".equals(captchaValidationParameter)) {
			throw new IllegalArgumentException("captchaValidationParameter must not be empty or null");
		}
	}
	public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
		this.publisher = applicationEventPublisher;
	}
	public void init(FilterConfig filterConfig) throws ServletException {
		//no implementation - we do everything in Spring lifecycle
	}
	public void destroy() {
		//no implementation - we do everything in Spring lifecycle
	}
	public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
		if((servletRequest != null) && servletRequest instanceof HttpServletRequest) {
			HttpServletRequest request = ((HttpServletRequest)servletRequest);
			HttpServletResponse response = ((HttpServletResponse)servletResponse);
			if(((HttpServletRequest)servletRequest).getRequestURI().endsWith(captchaImgUrl)) {
				generateImageOutputAndSkipProcessing(request, response);
			}
			else {
				String captchaResponse = request.getParameter(captchaValidationParameter);
				boolean continueWithChainProcessing = true;
				if(captchaResponse != null) {
					continueWithChainProcessing =
							validateCaptchaResponse(request, response, captchaResponse);
				} else {
					log.debug("Captcha validation parameter not found, do nothing");
				}
				if (continueWithChainProcessing) {
					if(log.isDebugEnabled()) {
						log.debug("Continuing with chain processing ...");
					}
					chain.doFilter(request, response);
				}
			}
		}
		else {
			chain.doFilter(servletRequest, servletResponse);
		}
	}
	/**
	 * Method validates captcha response and fires appropriate events.
	 * @param request
	 * @param response
	 * @param captchaResponse
	 *
	 * @return true if filter processing should continue, false when redirect was made
	 */
	private boolean validateCaptchaResponse(HttpServletRequest request, HttpServletResponse response, String captchaResponse) throws IOException {
		log.debug("Captcha validation parameter found, trying to validate");
		// validate the request against CaptchaServiceProxy
		HttpSession session = request.getSession();
		if(session != null) {
			String id = session.getId();
			boolean valid = false;
			try {
				valid = this.captchaService.validateReponseForId(id, captchaResponse);
			} catch(CaptchaServiceException ex) {
				if(log.isWarnEnabled()) {
					log.warn("Captcha already used. Returning false for recognition result!");
				}
			}
			log.debug("CaptchaServiceProxy says : request is valid = " + valid);
			if(valid) {
				log.debug("Captcha response accepted - updating context");
				((CaptchaSecurityContext)SecurityContextHolder.getContext()).setHuman();
				publisher.publishEvent(
						new CaptchaChallengePassedEvent(this, captchaResponse)
				);
				//if there is saved request redirect to it
				SavedRequest savedRequest = (SavedRequest)request.getSession()
						.getAttribute(SelfAwareCaptchaEntryPoint.CAPTCHA_ENTRY_POINT_SAVED_REQUEST_ATTRIBUTE);
				if (savedRequest != null) {
					String url = savedRequest.getFullRequestUrl();
					if(log.isDebugEnabled()) {
						log.debug("Original captcha requested url (" + url + ") found. Redirecting back because captcha was accepted.");
					}
					response.sendRedirect(url);
					return false;
				}
			}
			else {
				log.debug("Captcha response rejected - wrong answer");
				request.setAttribute(REQUEST_CAPTCHA_RECOGNITION_FAILURE_FLAG, Boolean.TRUE);
				publisher.publishEvent(
						new CaptchaChallengeFailedEvent(this, captchaResponse)
				);
			}
		}
		else {
			log.debug("No session found, user didn't even ask a captcha challenge");
		}
		return true;
	}
	/**
	 * This method just generates captcha image and skips request processing.
	 *
	 * @param request
	 * @param response
	 * @throws IOException
	 */
	private void generateImageOutputAndSkipProcessing(HttpServletRequest request, HttpServletResponse response) throws IOException {
		//we skip processing and return generated captcha image
		response.setHeader("Cache-Control", "no-store");
		response.setHeader("Pragma", "no-cache");
		response.setDateHeader("Expires", 0);
		response.setContentType("image/jpeg");
		// get the session id that will identify the generated captcha.
		//the same id must be used to validate the response, the session id is a good candidate!
		//generated image we stream directly onto output
		generateCaptcha(request.getSession().getId(), request.getLocale(), response.getOutputStream());
	}
	/**
	 * Generates the captcha image;
	 *
	 * @param captchaId
	 * @param locale
	 * @param os
	 * @throws IOException
	 */
	private void generateCaptcha(String captchaId, Locale locale, OutputStream os) throws IOException {
		if (jcaptchaService instanceof ImageCaptchaService) {
			BufferedImage challenge = ((ImageCaptchaService)jcaptchaService)
					.getImageChallengeForID(captchaId, locale);
			JPEGImageEncoder jpegEncoder = JPEGCodec.createJPEGEncoder(os);
			jpegEncoder.encode(challenge);
			// flush it in the response
			os.flush();
			os.close();
		} else {
			String msg = "CaptchaService does not implement ImageCaptchaService. Cannot generate image captcha.";
			log.error(msg);
		}
	}
}

Tento přístup se liší od standardní implementace tím, že vůbec nepracuje s patterny chráněných url. Pouze jednoduše pro určité url (/generatedCaptchaImage.jpg) vrací vygenerovanou captchu a při konkrétním parametru v requestu se naopak pokouší validovat captchu. Ve každém případě však propouští zpracování requestu dál, tzn. vlastní url formuláře není tímto filtrem nijak chráněno.

Zaznamenání chyby při validaci formuláře

Filtr není závislý na žádném web frameworku, jediný jeho výstup je nastavení příznaku v SecurityContextu nebo specifického atributu v requestu. Vlastní ochranu formuláře musíme tedy řešit až o krok dále - na úrovni validačního rámce konkrétního použitého frameworku. V některých bude tato ochrana velmi triviální, někde se můžeme při implementaci ochrany docela nadřít. Nejlepším způsobem je se vetřít do standardní validace konkrétního validačního rámce a využít jej i k propagaci chybového hlášení, zpracování flow při chybě, obarvení chybových polí atp. - tím si ušetříme spoustu práce.

V mém případě se jednalo o integraci do frameworku Stripes a tam je tato záležitost zcela přímočará. Spočívá v implementaci Interceptoru, který se spouští ve fázi BindingAndValidation a který pouze prověří přítomnost atributu signalizujícího špatně rozeznanou captchu. V takovém případě automaticky přidá do seznamu chyb nový záznam, což způsobí, že zpracování requestu skončí ve fázi validace dat a vrátí se zpět na původní formulář.

Obdobně by bylo asi velmi jednoduché implementovat tento způsob validace i v JSF frameworcích. Obtížně si jeho realizaci naopak umím představit ve Strutsech (minimálně v 1.2.X verzi).

@Intercepts(LifecycleStage.BindingAndValidation)
public class CaptchaValidationInterceptor implements Interceptor {
	/**
	 * Invoked when intercepting the flow of execution.
	 *
	 * @param context the ExecutionContext of the request currently being processed
	 * @return the result of calling context.proceed(), or if the interceptor wishes to change
	 *         the flow of execution, a Resolution
	 * @throws Exception if any non-recoverable errors occur
	 */
	public Resolution intercept(ExecutionContext context) throws Exception {
		Boolean captchaRecognitionFailed = (Boolean)context.getActionBeanContext()
				.getRequest().getAttribute(
				CaptchaGenerationValidationProcessingFilter.REQUEST_CAPTCHA_RECOGNITION_FAILURE_FLAG
		);
		if (captchaRecognitionFailed != null && captchaRecognitionFailed.booleanValue()) {
			context.getActionBeanContext().getValidationErrors().add(
					"captcha", new SimpleError("Chybně rozpoznaná captcha.")
			);
		}
		return context.proceed();
	}
}

Webová vrstva - formulář a zobrazení captchy

V JSP stránce přidáme do formuláře IMG tag, který se odkazuje na "virtuální url", na které bude reagovat CaptchaGenerationValidationProcessingFilter vrácením nového Captcha obrázku. Ve formuláři bude taktéž k dispozici textové políčko pro zapsání odpovědi opět s názvem, který daný filtr očekává. Celá tato část je uzavřená do IF klauzule, která prověřuje, zda již test "humanity" pro aktuálního uživatele (session) náhodou nebyl v minulosti proveden. Pokud ano, zmizí captcha z formuláře. Pokud tedy uživatel bude ve vaší aplikaci vyplňovat více formulářů chráněných captchou, bude muset tuto captchu vyplnit pouze v prvním formuláři, a když jej aplikace úspěšně prověří, v dalších formulářích již nebude uživatel obtěžován.

<stripes:form action="/url.x" class="in" focus="">
<h3>Odeslat dotaz:</h3>
	<c:if test="<%=!((CaptchaSecurityContext)SecurityContextHolder.getContext()).isHuman()%>">
		<span id="captchaArea">
			<img id="captchaImage" src="/srv/www/generatedCaptchaImage.jpg" width="208" height="44">
			<span>
				<label for="captcha">Opište prosím text z obrázku:</label>
				<stripes:text id="captcha" name="captcha" class="text"/>
			</span>
		</span>
	</c:if>
	<stripes:submit name="createItem"/>
</stripes:form>

Závěrem

Jak jsem již v článku uvedl, integrace captchy v Acegi frameworku se mi zdá prapodivná. Na stranu druhou jsem si vědom, že přímá integrace do formulářů s sebou nese dodatečné problémy - není možné přímo v Acegi implementovat dostatečnou podporu, jelikož zobrazování chyb se musí provádět na úrovni konkrétního použitého web frameworku. Taktéž toto řešení vyžaduje, aby se v každém formuláři vložila část obsahující captchu a kontrolní pole. Toto si museli autoři zcela jistě uvědomovat - bohužel už o tom ale nikde nenajdete zmínku a musíte nad tím přemýšlet sami.

Na úplný závěr si ještě neodpustím jeden povzdech. Z několika přednášek o tvorbě API jsem si odnesl pravidla:

  • nedělejte API širší než je nezbytně nutné,
  • příliš neotvírejte třídy k dědičnosti a nebojte se použít final,
  • omezte viditelnost metod na maximální možnou míru

Z pohledu vývojáře API dávají pravidla smysl. Má daleko větší kontrolu nad svým API a především má daleko větší možnosti refaktoringu a změn v něm.

Z pohledu uživatele API to však vede místy k naprostému zoufalství. V Acegi je většina tříd poměrně slušně připravená k rozšiřování, ovšem najdete tam i takové kousky, kdy je veškerá logika třídy v jedné metodě, nebo naopak jsou důležité metody jako friendly nebo private (aniž by to dávalo zjevný smysl). Na obdobné potíže jsme narazili i v knihovně jCaptcha a častokrát na místech, kde jsme to naprosto nečekali a kde nám to ani nedává smysl.

Jediným výsledkem je bohužel to, že buď to přímo zkopírujete kusy zdrojáku z originálu, nebo použijete reflection ke znásilnění původního kódu. Prostě není jiná možnost - výsledkem je, že tvůrci API svoji kompatibilitu neochránili, pouze si udělali alibi, že při vydání nové verze neručí za to, když "vám to přestane fungovat".