001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.wicket.extensions.markup.html.captcha; 018 019import java.awt.BasicStroke; 020import java.awt.Color; 021import java.awt.Font; 022import java.awt.Graphics2D; 023import java.awt.Shape; 024import java.awt.font.FontRenderContext; 025import java.awt.font.TextLayout; 026import java.awt.geom.AffineTransform; 027import java.awt.image.BufferedImage; 028import java.awt.image.WritableRaster; 029import java.lang.ref.SoftReference; 030import java.security.NoSuchAlgorithmException; 031import java.security.Provider; 032import java.security.SecureRandom; 033import java.security.Security; 034import java.time.Instant; 035import java.util.ArrayList; 036import java.util.Arrays; 037import java.util.List; 038import java.util.Random; 039import org.apache.wicket.model.IModel; 040import org.apache.wicket.model.Model; 041import org.apache.wicket.request.resource.DynamicImageResource; 042import org.apache.wicket.util.io.IClusterable; 043 044 045/** 046 * Generates a captcha image. 047 * 048 * @author Joshua Perlow 049 */ 050public class CaptchaImageResource extends DynamicImageResource 051{ 052 /** 053 * This class is used to encapsulate all the filters that a character will get when rendered. 054 * The changes are kept so that the size of the shapes can be properly recorded and reproduced 055 * later, since it dynamically generates the size of the captcha image. The reason I did it this 056 * way is because none of the JFC graphics classes are serializable, so they cannot be instance 057 * variables here. 058 */ 059 private static final class CharAttributes implements IClusterable 060 { 061 private static final long serialVersionUID = 1L; 062 private final char c; 063 private final String name; 064 private final int rise; 065 private final double rotation; 066 private final double shearX; 067 private final double shearY; 068 069 CharAttributes(final char c, final String name, final double rotation, final int rise, 070 final double shearX, final double shearY) 071 { 072 this.c = c; 073 this.name = name; 074 this.rotation = rotation; 075 this.rise = rise; 076 this.shearX = shearX; 077 this.shearY = shearY; 078 } 079 080 char getChar() 081 { 082 return c; 083 } 084 085 String getName() 086 { 087 return name; 088 } 089 090 int getRise() 091 { 092 return rise; 093 } 094 095 double getRotation() 096 { 097 return rotation; 098 } 099 100 double getShearX() 101 { 102 return shearX; 103 } 104 105 double getShearY() 106 { 107 return shearY; 108 } 109 } 110 111 private static final long serialVersionUID = 1L; 112 113 private static int randomInt(final Random rng, final int min, final int max) 114 { 115 return (int) (rng.nextDouble() * (max - min) + min); 116 } 117 118 private static String randomString(final Random rng, final int min, final int max) 119 { 120 int num = randomInt(rng, min, max); 121 byte b[] = new byte[num]; 122 for (int i = 0; i < num; i++) 123 { 124 b[i] = (byte) randomInt(rng, 'a', 'z'); 125 } 126 return new String(b); 127 } 128 129 private static final RandomNumberGeneratorFactory RNG_FACTORY = new RandomNumberGeneratorFactory(); 130 131 private final IModel<String> challengeId; 132 133 private final List<String> fontNames = Arrays.asList("Helvetica", "Arial", "Courier"); 134 private final int fontSize; 135 private final int fontStyle; 136 137 /** 138 * Transient image data so that image only needs to be re-generated after de-serialization 139 */ 140 private transient SoftReference<byte[]> imageData; 141 142 private final int margin; 143 private final Random rng; 144 145 /** 146 * Construct. 147 */ 148 public CaptchaImageResource() 149 { 150 this(randomString(RNG_FACTORY.newRandomNumberGenerator(), 10, 14)); 151 } 152 153 /** 154 * Construct. 155 * 156 * @param challengeId 157 * The id of the challenge 158 */ 159 public CaptchaImageResource(final String challengeId) 160 { 161 this(Model.of(challengeId)); 162 } 163 164 /** 165 * Construct. 166 * 167 * @param challengeId 168 * The id of the challenge 169 */ 170 public CaptchaImageResource(final IModel<String> challengeId) 171 { 172 this(challengeId, 48, 30); 173 } 174 175 /** 176 * Construct. 177 * 178 * @param challengeId 179 * The id of the challenge 180 * @param fontSize 181 * The font size 182 * @param margin 183 * The image's margin 184 */ 185 public CaptchaImageResource(final IModel<String> challengeId, final int fontSize, 186 final int margin) 187 { 188 this.challengeId = challengeId; 189 this.fontStyle = 1; 190 this.fontSize = fontSize; 191 this.margin = margin; 192 this.rng = newRandomNumberGenerator(); 193 } 194 195 /** 196 * Construct. 197 * 198 * @param challengeId 199 * The id of the challenge 200 * @param fontSize 201 * The font size 202 * @param margin 203 * The image's margin 204 */ 205 public CaptchaImageResource(final String challengeId, final int fontSize, final int margin) 206 { 207 this(Model.of(challengeId), fontSize, margin); 208 } 209 210 protected Random newRandomNumberGenerator() 211 { 212 return RNG_FACTORY.newRandomNumberGenerator(); 213 } 214 215 /** 216 * Gets the id for the challenge. 217 * 218 * @return The id for the challenge 219 */ 220 public final String getChallengeId() 221 { 222 return challengeId.getObject(); 223 } 224 225 /** 226 * Gets the id for the challenge 227 * 228 * @return The id for the challenge 229 */ 230 public final IModel<String> getChallengeIdModel() 231 { 232 return challengeId; 233 } 234 235 /** 236 * Causes the image to be redrawn the next time its requested. 237 */ 238 public final void invalidate() 239 { 240 imageData = null; 241 } 242 243 @Override 244 protected final byte[] getImageData(final Attributes attributes) 245 { 246 // get image data is always called in sync block 247 byte[] data = null; 248 if (imageData != null) 249 { 250 data = imageData.get(); 251 } 252 if (data == null) 253 { 254 data = render(); 255 imageData = new SoftReference<>(data); 256 setLastModifiedTime(Instant.now()); 257 } 258 return data; 259 } 260 261 private Font getFont(final String fontName) 262 { 263 return new Font(fontName, fontStyle, fontSize); 264 } 265 266 /** 267 * Renders this image 268 * 269 * @return The image data 270 */ 271 protected byte[] render() 272 { 273 int width = margin * 2; 274 int height = margin * 2; 275 char[] chars = challengeId.getObject().toCharArray(); 276 List<CharAttributes> charAttsList = new ArrayList<>(); 277 TextLayout text; 278 AffineTransform textAt; 279 Shape shape; 280 281 for (char ch : chars) 282 { 283 String fontName = fontNames.get(randomInt(rng, 0, fontNames.size())); 284 double rotation = Math.toRadians(randomInt(rng, -35, 35)); 285 int rise = randomInt(rng, margin / 2, margin); 286 287 double shearX = rng.nextDouble() * 0.2; 288 double shearY = rng.nextDouble() * 0.2; 289 CharAttributes cf = new CharAttributes(ch, fontName, rotation, rise, shearX, shearY); 290 charAttsList.add(cf); 291 text = new TextLayout(ch + "", getFont(fontName), new FontRenderContext(null, false, 292 false)); 293 textAt = new AffineTransform(); 294 textAt.rotate(rotation); 295 textAt.shear(shearX, shearY); 296 shape = text.getOutline(textAt); 297 width += (int) shape.getBounds2D().getWidth(); 298 if (height < (int) shape.getBounds2D().getHeight() + rise) 299 { 300 height = (int) shape.getBounds2D().getHeight() + rise; 301 } 302 } 303 304 final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); 305 Graphics2D gfx = (Graphics2D) image.getGraphics(); 306 gfx.setBackground(Color.WHITE); 307 int curWidth = margin; 308 for (CharAttributes cf : charAttsList) 309 { 310 text = new TextLayout(cf.getChar() + "", getFont(cf.getName()), 311 gfx.getFontRenderContext()); 312 textAt = new AffineTransform(); 313 textAt.translate(curWidth, height - cf.getRise()); 314 textAt.rotate(cf.getRotation()); 315 textAt.shear(cf.getShearX(), cf.getShearY()); 316 shape = text.getOutline(textAt); 317 curWidth += shape.getBounds().getWidth(); 318 gfx.setXORMode(Color.BLACK); 319 gfx.fill(shape); 320 } 321 322 // XOR circle 323 int dx = randomInt(rng, width, 2 * width); 324 int dy = randomInt(rng, width, 2 * height); 325 int x = randomInt(rng, 0, width / 2); 326 int y = randomInt(rng, 0, height / 2); 327 328 gfx.setXORMode(Color.BLACK); 329 gfx.setStroke(new BasicStroke(randomInt(rng, fontSize / 8, fontSize / 2))); 330 gfx.drawOval(x, y, dx, dy); 331 332 WritableRaster rstr = image.getRaster(); 333 int[] vColor = new int[3]; 334 int[] oldColor = new int[3]; 335 336 // noise 337 for (x = 0; x < width; x++) 338 { 339 for (y = 0; y < height; y++) 340 { 341 rstr.getPixel(x, y, oldColor); 342 343 // hard noise 344 vColor[0] = (int) (Math.floor(rng.nextFloat() * 1.03) * 255); 345 // soft noise 346 vColor[0] = vColor[0] ^ (170 + (int) (rng.nextFloat() * 80)); 347 // xor to image 348 vColor[0] = vColor[0] ^ oldColor[0]; 349 vColor[1] = vColor[0]; 350 vColor[2] = vColor[0]; 351 352 rstr.setPixel(x, y, vColor); 353 } 354 } 355 return toImageData(image); 356 } 357 358 /** 359 * The {@code RandomNumberGeneratorFactory} uses {@link java.security.SecureRandom} as RNG and {@code NativePRNG} 360 * on unix and {@code Windows-PRNG} on windows if it exists. Else it will fallback to {@code SHA1PRNG}. 361 * <p/> 362 * Please keep in mind that {@link java.security.SecureRandom} usesĀ {@code /dev/random} as default on unix systems 363 * which is a blocking call. It is possible to change this by adding {@code -Djava.security.egd=file:/dev/urandom} 364 * to your application server startup script. 365 */ 366 private static final class RandomNumberGeneratorFactory 367 { 368 private final Provider.Service service; 369 370 RandomNumberGeneratorFactory() 371 { 372 this.service = detectBestFittingService(); 373 } 374 375 /** 376 * Checks all existing security providers and returns the best fitting service. 377 * 378 * This method is different to {@link java.security.SecureRandom#getPrngAlgorithm()} which uses the first PRNG 379 * algorithm of the first provider that has registered a SecureRandom implementation. 380 * {@code detectBestFittingService()} instead uses a native PRNG if available, then 381 * {@code SHA1PRNG} else {@code null} which triggers {@link java.security.SecureRandom#getPrngAlgorithm()} 382 * when calling {@code new SecureRandom()}. 383 * 384 * @return a native pseudo random number generator or sha1 as fallback. 385 */ 386 private Provider.Service detectBestFittingService() 387 { 388 Provider.Service _sha1Service = null; 389 390 for (Provider provider : Security.getProviders()) 391 { 392 for (Provider.Service service : provider.getServices()) 393 { 394 if ("SecureRandom".equals(service.getType())) 395 { 396 String algorithm = service.getAlgorithm(); 397 if ("NativePRNG".equals(algorithm)) 398 { 399 return service; 400 } 401 else if ("Windows-PRNG".equals(algorithm)) 402 { 403 return service; 404 } 405 else if (_sha1Service == null && "SHA1PRNG".equals(algorithm)) 406 { 407 _sha1Service = service; 408 } 409 } 410 } 411 } 412 413 return _sha1Service; 414 } 415 416 /** 417 * @return new secure random number generator instance using best fitting service 418 */ 419 Random newRandomNumberGenerator() 420 { 421 if (service != null) 422 { 423 try 424 { 425 return SecureRandom.getInstance(service.getAlgorithm(), service.getProvider()); 426 } 427 catch (NoSuchAlgorithmException nsax) 428 { 429 // this shouldn't happen, because 'detectBestFittingService' has checked for existing provider and 430 // algorithms. 431 } 432 } 433 434 return new SecureRandom(); 435 } 436 } 437}