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}