/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.wicket.extensions.markup.html.captcha; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.Shape; import java.awt.font.FontRenderContext; import java.awt.font.TextLayout; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.awt.image.WritableRaster; import java.lang.ref.SoftReference; import java.security.NoSuchAlgorithmException; import java.security.Provider; import java.security.SecureRandom; import java.security.Security; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Random; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.request.resource.DynamicImageResource; import org.apache.wicket.util.io.IClusterable; import org.apache.wicket.util.time.Time; /** * Generates a captcha image. * * @author Joshua Perlow */ public class CaptchaImageResource extends DynamicImageResource { /** * This class is used to encapsulate all the filters that a character will get when rendered. * The changes are kept so that the size of the shapes can be properly recorded and reproduced * later, since it dynamically generates the size of the captcha image. The reason I did it this * way is because none of the JFC graphics classes are serializable, so they cannot be instance * variables here. */ private static final class CharAttributes implements IClusterable { private static final long serialVersionUID = 1L; private final char c; private final String name; private final int rise; private final double rotation; private final double shearX; private final double shearY; CharAttributes(final char c, final String name, final double rotation, final int rise, final double shearX, final double shearY) { this.c = c; this.name = name; this.rotation = rotation; this.rise = rise; this.shearX = shearX; this.shearY = shearY; } char getChar() { return c; } String getName() { return name; } int getRise() { return rise; } double getRotation() { return rotation; } double getShearX() { return shearX; } double getShearY() { return shearY; } } private static final long serialVersionUID = 1L; private static int randomInt(final Random rng, final int min, final int max) { return (int) (rng.nextDouble() * (max - min) + min); } private static String randomString(final Random rng, final int min, final int max) { int num = randomInt(rng, min, max); byte b[] = new byte[num]; for (int i = 0; i < num; i++) { b[i] = (byte) randomInt(rng, 'a', 'z'); } return new String(b); } private static final RandomNumberGeneratorFactory RNG_FACTORY = new RandomNumberGeneratorFactory(); private final IModel<String> challengeId; private final List<String> fontNames = Arrays.asList("Helvetica", "Arial", "Courier"); private final int fontSize; private final int fontStyle; /** * Transient image data so that image only needs to be re-generated after de-serialization */ private transient SoftReference<byte[]> imageData; private final int margin; private final Random rng; /** * Construct. */ public CaptchaImageResource() { this(randomString(RNG_FACTORY.newRandomNumberGenerator(), 10, 14)); } /** * Construct. * * @param challengeId * The id of the challenge */ public CaptchaImageResource(final String challengeId) { this(Model.of(challengeId)); } /** * Construct. * * @param challengeId * The id of the challenge */ public CaptchaImageResource(final IModel<String> challengeId) { this(challengeId, 48, 30); } /** * Construct. * * @param challengeId * The id of the challenge * @param fontSize * The font size * @param margin * The image's margin */ public CaptchaImageResource(final IModel<String> challengeId, final int fontSize, final int margin) { this.challengeId = challengeId; this.fontStyle = 1; this.fontSize = fontSize; this.margin = margin; this.rng = newRandomNumberGenerator(); } /** * Construct. * * @param challengeId * The id of the challenge * @param fontSize * The font size * @param margin * The image's margin */ public CaptchaImageResource(final String challengeId, final int fontSize, final int margin) { this(Model.of(challengeId), fontSize, margin); } protected Random newRandomNumberGenerator() { return RNG_FACTORY.newRandomNumberGenerator(); } /** * Gets the id for the challenge. * * @return The id for the challenge */ public final String getChallengeId() { return challengeId.getObject(); } /** * Gets the id for the challenge * * @return The id for the challenge */ public final IModel<String> getChallengeIdModel() { return challengeId; } /** * Causes the image to be redrawn the next time its requested. */ public final void invalidate() { imageData = null; } @Override protected final byte[] getImageData(final Attributes attributes) { // get image data is always called in sync block byte[] data = null; if (imageData != null) { data = imageData.get(); } if (data == null) { data = render(); imageData = new SoftReference<>(data); setLastModifiedTime(Time.now()); } return data; } private Font getFont(final String fontName) { return new Font(fontName, fontStyle, fontSize); } /** * Renders this image * * @return The image data */ protected byte[] render() { int width = margin * 2; int height = margin * 2; char[] chars = challengeId.getObject().toCharArray(); List<CharAttributes> charAttsList = new ArrayList<>(); TextLayout text; AffineTransform textAt; Shape shape; for (char ch : chars) { String fontName = fontNames.get(randomInt(rng, 0, fontNames.size())); double rotation = Math.toRadians(randomInt(rng, -35, 35)); int rise = randomInt(rng, margin / 2, margin); double shearX = rng.nextDouble() * 0.2; double shearY = rng.nextDouble() * 0.2; CharAttributes cf = new CharAttributes(ch, fontName, rotation, rise, shearX, shearY); charAttsList.add(cf); text = new TextLayout(ch + "", getFont(fontName), new FontRenderContext(null, false, false)); textAt = new AffineTransform(); textAt.rotate(rotation); textAt.shear(shearX, shearY); shape = text.getOutline(textAt); width += (int) shape.getBounds2D().getWidth(); if (height < (int) shape.getBounds2D().getHeight() + rise) { height = (int) shape.getBounds2D().getHeight() + rise; } } final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D gfx = (Graphics2D) image.getGraphics(); gfx.setBackground(Color.WHITE); int curWidth = margin; for (CharAttributes cf : charAttsList) { text = new TextLayout(cf.getChar() + "", getFont(cf.getName()), gfx.getFontRenderContext()); textAt = new AffineTransform(); textAt.translate(curWidth, height - cf.getRise()); textAt.rotate(cf.getRotation()); textAt.shear(cf.getShearX(), cf.getShearY()); shape = text.getOutline(textAt); curWidth += shape.getBounds().getWidth(); gfx.setXORMode(Color.BLACK); gfx.fill(shape); } // XOR circle int dx = randomInt(rng, width, 2 * width); int dy = randomInt(rng, width, 2 * height); int x = randomInt(rng, 0, width / 2); int y = randomInt(rng, 0, height / 2); gfx.setXORMode(Color.BLACK); gfx.setStroke(new BasicStroke(randomInt(rng, fontSize / 8, fontSize / 2))); gfx.drawOval(x, y, dx, dy); WritableRaster rstr = image.getRaster(); int[] vColor = new int[3]; int[] oldColor = new int[3]; // noise for (x = 0; x < width; x++) { for (y = 0; y < height; y++) { rstr.getPixel(x, y, oldColor); // hard noise vColor[0] = (int) (Math.floor(rng.nextFloat() * 1.03) * 255); // soft noise vColor[0] = vColor[0] ^ (170 + (int) (rng.nextFloat() * 80)); // xor to image vColor[0] = vColor[0] ^ oldColor[0]; vColor[1] = vColor[0]; vColor[2] = vColor[0]; rstr.setPixel(x, y, vColor); } } return toImageData(image); } /** * The {@code RandomNumberGeneratorFactory} uses {@link java.security.SecureRandom} as RNG and {@code NativePRNG} * on unix and {@code Windows-PRNG} on windows if it exists. Else it will fallback to {@code SHA1PRNG}. * <p/> * Please keep in mind that {@link java.security.SecureRandom} usesĀ {@code /dev/random} as default on unix systems * which is a blocking call. It is possible to change this by adding {@code -Djava.security.egd=file:/dev/urandom} * to your application server startup script. */ private static final class RandomNumberGeneratorFactory { private final Provider.Service service; RandomNumberGeneratorFactory() { this.service = detectBestFittingService(); } /** * Checks all existing security providers and returns the best fitting service. * * This method is different to {@link java.security.SecureRandom#getPrngAlgorithm()} which uses the first PRNG * algorithm of the first provider that has registered a SecureRandom implementation. * {@code detectBestFittingService()} instead uses a native PRNG if available, then * {@code SHA1PRNG} else {@code null} which triggers {@link java.security.SecureRandom#getPrngAlgorithm()} * when calling {@code new SecureRandom()}. * * @return a native pseudo random number generator or sha1 as fallback. */ private Provider.Service detectBestFittingService() { Provider.Service _sha1Service = null; for (Provider provider : Security.getProviders()) { for (Provider.Service service : provider.getServices()) { if ("SecureRandom".equals(service.getType())) { String algorithm = service.getAlgorithm(); if ("NativePRNG".equals(algorithm)) { return service; } else if ("Windows-PRNG".equals(algorithm)) { return service; } else if (_sha1Service == null && "SHA1PRNG".equals(algorithm)) { _sha1Service = service; } } } } return _sha1Service; } /** * @return new secure random number generator instance using best fitting service */ Random newRandomNumberGenerator() { if (service != null) { try { return SecureRandom.getInstance(service.getAlgorithm(), service.getProvider()); } catch (NoSuchAlgorithmException nsax) { // this shouldn't happen, because 'detectBestFittingService' has checked for existing provider and // algorithms. } } return new SecureRandom(); } } }