001/*
002 *  Copyright 2014 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.captcha;
017
018import java.awt.Color;
019import java.awt.Font;
020import java.awt.image.BufferedImage;
021import java.io.ByteArrayOutputStream;
022import java.io.IOException;
023import java.io.InputStream;
024import java.util.ArrayList;
025import java.util.Calendar;
026import java.util.Date;
027import java.util.GregorianCalendar;
028import java.util.HashMap;
029import java.util.Iterator;
030import java.util.List;
031import java.util.Map;
032
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.commons.io.IOUtils;
037import org.apache.commons.lang.StringUtils;
038import org.apache.http.NameValuePair;
039import org.apache.http.client.config.RequestConfig;
040import org.apache.http.client.entity.UrlEncodedFormEntity;
041import org.apache.http.client.methods.CloseableHttpResponse;
042import org.apache.http.client.methods.HttpPost;
043import org.apache.http.impl.client.CloseableHttpClient;
044import org.apache.http.impl.client.HttpClientBuilder;
045import org.apache.http.message.BasicNameValuePair;
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048
049import org.ametys.core.util.JSONUtils;
050import org.ametys.runtime.config.Config;
051
052import nl.captcha.Captcha;
053import nl.captcha.Captcha.Builder;
054import nl.captcha.gimpy.DropShadowGimpyRenderer;
055import nl.captcha.gimpy.FishEyeGimpyRenderer;
056import nl.captcha.gimpy.RippleGimpyRenderer;
057import nl.captcha.noise.CurvedLineNoiseProducer;
058import nl.captcha.text.producer.DefaultTextProducer;
059import nl.captcha.text.renderer.DefaultWordRenderer;
060
061/**
062 * Helper for generating image captcha to PNG format
063 */
064public final class CaptchaHelper implements Serviceable
065{
066    private static final String CAPTCHA_TYPE_KEY = "runtime.captcha.type";
067    private static final String RECAPTCHA_SECRET_KEY = "runtime.captcha.recaptcha.secretkey";
068    private static final String STATIC_PREFIX_KEY = "STATIC-";
069    private static final String DYNAMIC_PREFIX_KEY = "DYNAMIC-";
070    
071    private static Map<String, List<ValidableCaptcha>> _mapStaticCaptcha = new HashMap<>();
072    private static Map<String, ValidableCaptcha> _mapDynamicCaptcha = new HashMap<>();
073    private static JSONUtils _jsonUtils;
074    
075    private static Logger _logger = LoggerFactory.getLogger(CaptchaHelper.class);
076    
077    public void service(ServiceManager manager) throws ServiceException
078    {
079        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
080    }
081    
082    enum CaptchaType 
083    {
084        /** Ametys Internal Captcha */
085        JCAPTCHA,
086        
087        /** Google reCaptcha */
088        RECAPTCHA
089    }
090    
091
092    /**
093     * Retrieve the type of captcha used
094     * @return The type of captcha.
095     */
096    public static String getCaptchaType()
097    {
098        if (Config.getInstance() != null)
099        {
100            return Config.getInstance().getValueAsString(CAPTCHA_TYPE_KEY);
101        }
102        else
103        {
104            return "jcaptcha";
105        }
106    }
107
108    /**
109     * Check a captcha
110     * @param key The captcha key. Can be empty or null when using reCaptcha.
111     * @param value The value to check
112     * @return <code>true</code> if the captcha is valid, false otherwise.
113     */
114    public static boolean checkAndInvalidate(String key, String value)
115    {
116        if (Config.getInstance() != null)
117        {
118            String captchaType = Config.getInstance().getValueAsString(CAPTCHA_TYPE_KEY);
119            
120            switch (CaptchaType.valueOf(captchaType.toUpperCase()))
121            {
122                case JCAPTCHA: 
123                    if (StringUtils.isEmpty(key) || StringUtils.isEmpty(value))
124                    {
125                        return false;
126                    }
127                    
128                    return checkAndInvalidateJCaptcha(key, value);
129                    
130                case RECAPTCHA:
131                    if (StringUtils.isEmpty(value))
132                    {
133                        return false;
134                    }
135                    
136                    return checkAndInvalidateReCaptcha(value);
137                    
138                default:
139                    return false;
140            }
141        }
142        else
143        {
144            // In safe mode, captcha will not work
145            return true;
146        }
147    }
148    
149    /**
150     * Check a captcha
151     * @param key The key
152     * @param value The value to check
153     * @return The image captcha
154     */
155    public static synchronized boolean checkAndInvalidateJCaptcha(String key, String value)
156    {
157        if (key.startsWith(STATIC_PREFIX_KEY))
158        {
159            boolean atLeastAnInvalid = false;
160            boolean foundOne = false;
161            
162            List<ValidableCaptcha> list = _mapStaticCaptcha.get(key);
163            if (list != null)
164            {
165                for (ValidableCaptcha c : list)
166                {
167                    if (c.isValid())
168                    {
169                        Captcha captcha = c.getCaptcha();
170                        if (captcha.isCorrect(value))
171                        {
172                            foundOne = true;
173        
174                            c.invalidate();
175                            atLeastAnInvalid = true;
176                            
177                            break;
178                        }
179                    }
180                    else
181                    {
182                        atLeastAnInvalid = true;
183                    }
184                }
185            }
186            
187            if (atLeastAnInvalid)
188            {
189                cleanOldCaptchas();
190            }
191            
192            return foundOne;
193        }
194        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
195        {
196            ValidableCaptcha vc = _mapDynamicCaptcha.get(key);
197            if (vc == null)
198            {
199                return false;
200            }
201            else if (!vc.isValid())
202            {
203                _mapDynamicCaptcha.remove(key);
204                return false;
205            }
206            else
207            {
208                _mapDynamicCaptcha.remove(key);
209
210                Captcha c = vc.getCaptcha();
211                return c.isCorrect(value);
212            }
213        }
214        else
215        {
216            throw new IllegalArgumentException("The key '" + key + "' is not a valid captcha key because it does not starts with '" + DYNAMIC_PREFIX_KEY + "' or '" + STATIC_PREFIX_KEY + "'");
217        }
218    }
219    
220    /**
221     * Check a ReCaptcha value
222     * @param value The value to check
223     * @return True if the captcha is valid.
224     */
225    public static boolean checkAndInvalidateReCaptcha(String value)
226    {
227        if (Config.getInstance() != null)
228        {
229            String key = Config.getInstance().getValueAsString(RECAPTCHA_SECRET_KEY);
230            
231            String url = "https://www.google.com/recaptcha/api/siteverify";
232            
233            RequestConfig requestConfig = RequestConfig.custom()
234                    .setConnectTimeout(2000)
235                    .setSocketTimeout(2000)
236                    .build();
237            
238            try (CloseableHttpClient httpclient = HttpClientBuilder.create()
239                                                                   .setDefaultRequestConfig(requestConfig)
240                                                                   .useSystemProperties()
241                                                                   .build())
242            {
243                // Prepare a request object
244                HttpPost post = new HttpPost(url);
245                List<NameValuePair> params = new ArrayList<>();
246                params.add(new BasicNameValuePair("secret", key));
247                params.add(new BasicNameValuePair("response", value));
248                post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
249                
250                try (CloseableHttpResponse httpResponse = httpclient.execute(post))
251                {
252                    if (httpResponse.getStatusLine().getStatusCode() != 200)
253                    {
254                        return false;
255                    }
256                    
257                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
258                    try (InputStream is = httpResponse.getEntity().getContent())
259                    {
260                        IOUtils.copy(is, bos);
261                    }
262                    
263                    Map<String, Object> jsonObject = _jsonUtils.convertJsonToMap(bos.toString());
264                    
265                    return jsonObject.containsKey("success") && (Boolean) jsonObject.get("success");
266                }
267            }
268            catch (IOException e)
269            {
270                _logger.error("Unable to concat Google server to validate reCaptcha.", e);
271                return false;
272            }
273        }
274        
275        return false;
276    }
277    
278    /**
279     * Remove a captcha
280     * @param key the key value
281     */
282    public static synchronized void removeCaptcha (String key)
283    {
284        if (key.startsWith(STATIC_PREFIX_KEY))
285        {
286            _mapStaticCaptcha.remove(key);
287        }
288        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
289        {
290            _mapDynamicCaptcha.remove(key);
291        }
292    }
293    
294    /**
295     * Clean the outdated captchas
296     */
297    public static synchronized void cleanOldCaptchas()
298    {
299        _cleanOldStaticCaptchas();
300        _cleanOldDynamicCaptchas();
301    }
302    
303    private static synchronized void _cleanOldDynamicCaptchas()
304    {
305        Iterator<String> cIt = _mapDynamicCaptcha.keySet().iterator();
306        while (cIt.hasNext())
307        {
308            String id = cIt.next();
309            ValidableCaptcha vc = _mapDynamicCaptcha.get(id);
310            if (!vc.isValid())
311            {
312                cIt.remove();
313            }
314        }
315    }
316    
317    private static synchronized void _cleanOldStaticCaptchas()
318    {
319        Iterator<String> cIt = _mapStaticCaptcha.keySet().iterator();
320        while (cIt.hasNext())
321        {
322            String id = cIt.next();
323            List<ValidableCaptcha> c = _mapStaticCaptcha.get(id);
324            
325            Iterator<ValidableCaptcha> it = c.iterator();
326            while (it.hasNext())
327            {
328                ValidableCaptcha vc  = it.next();
329                if (!vc.isValid())
330                {
331                    it.remove();
332                }
333            }
334            
335            if (c.isEmpty())
336            {
337                cIt.remove();
338            }
339        }
340    }
341
342    /**
343     * Generate an image captcha to PNG format. The key has to be unique.
344     * If you can not give a unique id use generateImageCaptch without the key argument : but this is less secure.
345     * @param key the wanted key. Can be not null. MUST START with "STATIC-" or "DYNAMIC-". If the key starts with 'STATIC-' this key may be used several times (e.g. for a cached page with a unique id for several display), if the key starts with 'DYNAMIC-' the key will unique (removing an existing captcha with the same key).
346     * @return The corresponding image
347     */
348    public static BufferedImage generateImageCaptcha (String key)
349    {
350        return generateImageCaptcha(key, 0x000000);
351    }
352    
353    /**
354     * Generate an image captcha to PNG format. The key has to be unique.
355     * If you can not give a unique id use generateImageCaptch without the key argument : but this is less secure.
356     * @param key the wanted key. Can be not null. MUST START with "STATIC-" or "DYNAMIC-". If the key starts with 'STATIC-' this key may be used several times (e.g. for a cached page with a unique id for several display), if the key starts with 'DYNAMIC-' the key will unique (removing an existing captcha with the same key).
357     * @param addNoise true to add noise to captcha image
358     * @param fisheye true to add fish eye background to captcha image
359     * @return The corresponding image
360     */
361    public static BufferedImage generateImageCaptcha (String key, boolean addNoise, boolean fisheye)
362    {
363        return generateImageCaptcha(key, 0x000000, addNoise, fisheye, 200, 50);
364    }
365    
366    /**
367     * Generate an image captcha to PNG format. The key has to be unique, if you cannot generate a key use the other form of the method.
368     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
369     * @param color The color for font
370     * @return The corresponding image
371     */
372    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color)
373    {
374        return generateImageCaptcha(key, color, false, false, 200, 50);
375    }
376    
377    /**
378     * Generate an image captcha to PNG format. The key has to be unique, if you cannot generate a key use the other form of the method.
379     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
380     * @param color The color for font
381     * @param addNoise true to add noise to captcha image
382     * @param fisheye true to add fish eye background to captcha image
383     * @return The corresponding image
384     */
385    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color, boolean addNoise, boolean fisheye)
386    {
387        return generateImageCaptcha (key, color, addNoise, fisheye, 200, 50);
388    }
389    
390    /**
391     * Generate an image captcha to PNG format. The key has to be unique, if you cannot generate a key use the other form of the method.
392     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
393     * @param color The color for font
394     * @param addNoise true to add noise to captcha image
395     * @param fisheye true to add fish eye background to captcha image
396     * @param width The image width
397     * @param height The image height
398     * @return The corresponding image
399     */
400    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color, boolean addNoise, boolean fisheye, int width, int height)
401    {
402        Captcha captcha = _generateImageCaptcha(color, addNoise, fisheye, width, height);
403        ValidableCaptcha vc = new ValidableCaptcha(captcha);
404        
405        if (key.startsWith(STATIC_PREFIX_KEY))
406        {
407            if (!_mapStaticCaptcha.containsKey(key))
408            {   
409                _mapStaticCaptcha.put(key, new ArrayList<ValidableCaptcha>());
410            }
411            List<ValidableCaptcha> captchas = _mapStaticCaptcha.get(key);
412            captchas.add(new ValidableCaptcha(captcha));
413        }
414        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
415        {
416            // If there were a key there, we scrach it! (this is a way to invalidate it - this may happen when the user do 'back' in its browser
417            _mapDynamicCaptcha.put(key, vc);
418        }
419        else
420        {
421            throw new IllegalArgumentException("The key '" + key + "' is not a valid captcha key because it does not starts with '" + DYNAMIC_PREFIX_KEY + "' or '" + STATIC_PREFIX_KEY + "'");
422        }
423        
424        return vc.getCaptcha().getImage();
425    }
426    
427    private static Captcha _generateImageCaptcha(Integer color, boolean addNoise, boolean fisheye, int width, int height)
428    {
429        List<Color> colors = new ArrayList<>();
430        colors.add(new Color(color));
431        
432        List<Font> fonts = new ArrayList<>();
433        fonts.add(new Font("Arial", Font.BOLD, 40));
434        fonts.add(new Font("Courier", Font.BOLD, 40));
435        
436        Builder builder = new Captcha.Builder(width, height)
437            .addText(new DefaultTextProducer(6, "abcdefghijklmnopqrstuvwxyz".toCharArray()), new DefaultWordRenderer(colors, fonts))
438            .gimp(new RippleGimpyRenderer())
439            .addNoise(new CurvedLineNoiseProducer(new Color(color), 3));
440        
441        if (addNoise)
442        {
443            builder.addNoise(new CurvedLineNoiseProducer(new Color(color), 3))
444                .gimp(new DropShadowGimpyRenderer());
445        }
446        
447        if (fisheye)
448        {
449            builder.gimp(new FishEyeGimpyRenderer());
450        }
451        
452        return builder.build();
453    }
454    
455    /**
456     * Bean for a captcha and a validity date 
457     */
458    static class ValidableCaptcha
459    {
460        private Captcha _captcha;
461        private Date _date;
462        private boolean _valid;
463        
464        /**
465         * Build the captcha wrapper
466         * @param c the captcha to wrap
467         */
468        public ValidableCaptcha(Captcha c)
469        {
470            _captcha = c;
471            _date = new Date();
472            _valid = true;
473        }
474
475        /**
476         * Determine if the validity date has not expired
477         * @return true if the captcha is still valid
478         */
479        public boolean isValid()
480        {
481            if (!_valid)
482            {
483                return false;
484            }
485            
486            Calendar validity = new GregorianCalendar();
487            validity.add(Calendar.MINUTE, -20);
488            
489            return _date.after(validity.getTime()); 
490        }
491        
492        /**
493         * Get the wrapper captcha
494         * @return captcha
495         */
496        public Captcha getCaptcha()
497        {
498            return _captcha;
499        }
500        
501        /**
502         * Mark the captcha as invalid 
503         */
504        public void invalidate()
505        {
506            _valid = false;
507        }
508    }
509}