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().getValue(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().getValue(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            List<ValidableCaptcha> list = _mapStaticCaptcha.get(key);
160            if (list != null)
161            {
162                for (ValidableCaptcha c : list)
163                {
164                    if (c.isValid())
165                    {
166                        Captcha captcha = c.getCaptcha();
167                        if (captcha.isCorrect(value))
168                        {
169                            c.invalidate();
170                            return true;
171                        }
172                    }
173                }
174            }
175            
176            return false;
177        }
178        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
179        {
180            ValidableCaptcha vc = _mapDynamicCaptcha.get(key);
181            if (vc == null)
182            {
183                return false;
184            }
185            else if (!vc.isValid())
186            {
187                _mapDynamicCaptcha.remove(key);
188                return false;
189            }
190            else
191            {
192                _mapDynamicCaptcha.remove(key);
193
194                Captcha c = vc.getCaptcha();
195                return c.isCorrect(value);
196            }
197        }
198        else
199        {
200            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 + "'");
201        }
202    }
203    
204    /**
205     * Check a ReCaptcha value
206     * @param value The value to check
207     * @return True if the captcha is valid.
208     */
209    public static boolean checkAndInvalidateReCaptcha(String value)
210    {
211        if (Config.getInstance() != null)
212        {
213            String key = Config.getInstance().getValue(RECAPTCHA_SECRET_KEY);
214            
215            String url = "https://www.google.com/recaptcha/api/siteverify";
216            
217            RequestConfig requestConfig = RequestConfig.custom()
218                    .setConnectTimeout(2000)
219                    .setSocketTimeout(2000)
220                    .build();
221            
222            try (CloseableHttpClient httpclient = HttpClientBuilder.create()
223                                                                   .setDefaultRequestConfig(requestConfig)
224                                                                   .useSystemProperties()
225                                                                   .build())
226            {
227                // Prepare a request object
228                HttpPost post = new HttpPost(url);
229                List<NameValuePair> params = new ArrayList<>();
230                params.add(new BasicNameValuePair("secret", key));
231                params.add(new BasicNameValuePair("response", value));
232                post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
233                
234                try (CloseableHttpResponse httpResponse = httpclient.execute(post))
235                {
236                    if (httpResponse.getStatusLine().getStatusCode() != 200)
237                    {
238                        return false;
239                    }
240                    
241                    ByteArrayOutputStream bos = new ByteArrayOutputStream();
242                    try (InputStream is = httpResponse.getEntity().getContent())
243                    {
244                        IOUtils.copy(is, bos);
245                    }
246                    
247                    Map<String, Object> jsonObject = _jsonUtils.convertJsonToMap(bos.toString());
248                    
249                    return (Boolean) jsonObject.getOrDefault("success", false);
250                }
251            }
252            catch (IOException e)
253            {
254                _logger.error("Unable to concat Google server to validate reCaptcha.", e);
255                return false;
256            }
257        }
258        
259        return false;
260    }
261    
262    /**
263     * Remove a captcha
264     * @param key the key value
265     */
266    public static synchronized void removeCaptcha (String key)
267    {
268        if (key.startsWith(STATIC_PREFIX_KEY))
269        {
270            _mapStaticCaptcha.remove(key);
271        }
272        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
273        {
274            _mapDynamicCaptcha.remove(key);
275        }
276    }
277    
278    /**
279     * Clean the outdated captchas
280     */
281    public static synchronized void cleanOldCaptchas()
282    {
283        _cleanOldStaticCaptchas();
284        _cleanOldDynamicCaptchas();
285    }
286    
287    private static synchronized void _cleanOldDynamicCaptchas()
288    {
289        Iterator<String> cIt = _mapDynamicCaptcha.keySet().iterator();
290        while (cIt.hasNext())
291        {
292            String id = cIt.next();
293            ValidableCaptcha vc = _mapDynamicCaptcha.get(id);
294            if (!vc.isValid())
295            {
296                cIt.remove();
297            }
298        }
299    }
300    
301    private static synchronized void _cleanOldStaticCaptchas()
302    {
303        Iterator<String> cIt = _mapStaticCaptcha.keySet().iterator();
304        while (cIt.hasNext())
305        {
306            String id = cIt.next();
307            List<ValidableCaptcha> c = _mapStaticCaptcha.get(id);
308            
309            Iterator<ValidableCaptcha> it = c.iterator();
310            while (it.hasNext())
311            {
312                ValidableCaptcha vc  = it.next();
313                if (!vc.isValid())
314                {
315                    it.remove();
316                }
317            }
318            
319            if (c.isEmpty())
320            {
321                cIt.remove();
322            }
323        }
324    }
325
326    /**
327     * Generate an image captcha to PNG format. The key has to be unique.
328     * If you can not give a unique id use generateImageCaptch without the key argument : but this is less secure.
329     * @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).
330     * @return The corresponding image
331     */
332    public static BufferedImage generateImageCaptcha (String key)
333    {
334        return generateImageCaptcha(key, 0x000000);
335    }
336    
337    /**
338     * Generate an image captcha to PNG format. The key has to be unique.
339     * If you can not give a unique id use generateImageCaptch without the key argument : but this is less secure.
340     * @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).
341     * @param addNoise true to add noise to captcha image
342     * @param fisheye true to add fish eye background to captcha image
343     * @return The corresponding image
344     */
345    public static BufferedImage generateImageCaptcha (String key, boolean addNoise, boolean fisheye)
346    {
347        return generateImageCaptcha(key, 0x000000, addNoise, fisheye, 200, 50);
348    }
349    
350    /**
351     * 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.
352     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
353     * @param color The color for font
354     * @return The corresponding image
355     */
356    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color)
357    {
358        return generateImageCaptcha(key, color, false, false, 200, 50);
359    }
360    
361    /**
362     * 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.
363     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
364     * @param color The color for font
365     * @param addNoise true to add noise to captcha image
366     * @param fisheye true to add fish eye background to captcha image
367     * @return The corresponding image
368     */
369    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color, boolean addNoise, boolean fisheye)
370    {
371        return generateImageCaptcha (key, color, addNoise, fisheye, 200, 50);
372    }
373    
374    /**
375     * 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.
376     * @param key the wanted key. Can not be null. You can use RandomStringUtils.randomAlphanumeric(10) to generates one
377     * @param color The color for font
378     * @param addNoise true to add noise to captcha image
379     * @param fisheye true to add fish eye background to captcha image
380     * @param width The image width
381     * @param height The image height
382     * @return The corresponding image
383     */
384    public static synchronized BufferedImage generateImageCaptcha (String key, Integer color, boolean addNoise, boolean fisheye, int width, int height)
385    {
386        Captcha captcha = _generateImageCaptcha(color, addNoise, fisheye, width, height);
387        ValidableCaptcha vc = new ValidableCaptcha(captcha);
388        
389        if (key.startsWith(STATIC_PREFIX_KEY))
390        {
391            if (!_mapStaticCaptcha.containsKey(key))
392            {   
393                _mapStaticCaptcha.put(key, new ArrayList<ValidableCaptcha>());
394            }
395            List<ValidableCaptcha> captchas = _mapStaticCaptcha.get(key);
396            captchas.add(new ValidableCaptcha(captcha));
397        }
398        else if (key.startsWith(DYNAMIC_PREFIX_KEY))
399        {
400            // 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
401            _mapDynamicCaptcha.put(key, vc);
402        }
403        else
404        {
405            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 + "'");
406        }
407        
408        return vc.getCaptcha().getImage();
409    }
410    
411    private static Captcha _generateImageCaptcha(Integer color, boolean addNoise, boolean fisheye, int width, int height)
412    {
413        List<Color> colors = new ArrayList<>();
414        colors.add(new Color(color));
415        
416        List<Font> fonts = new ArrayList<>();
417        fonts.add(new Font("Arial", Font.BOLD, 40));
418        fonts.add(new Font("Courier", Font.BOLD, 40));
419        
420        Builder builder = new Captcha.Builder(width, height)
421            .addText(new DefaultTextProducer(6, "abcdefghijklmnopqrstuvwxyz".toCharArray()), new DefaultWordRenderer(colors, fonts))
422            .gimp(new RippleGimpyRenderer())
423            .addNoise(new CurvedLineNoiseProducer(new Color(color), 3));
424        
425        if (addNoise)
426        {
427            builder.addNoise(new CurvedLineNoiseProducer(new Color(color), 3))
428                .gimp(new DropShadowGimpyRenderer());
429        }
430        
431        if (fisheye)
432        {
433            builder.gimp(new FishEyeGimpyRenderer());
434        }
435        
436        return builder.build();
437    }
438    
439    /**
440     * Bean for a captcha and a validity date 
441     */
442    static class ValidableCaptcha
443    {
444        private Captcha _captcha;
445        private Date _date;
446        private boolean _valid;
447        
448        /**
449         * Build the captcha wrapper
450         * @param c the captcha to wrap
451         */
452        public ValidableCaptcha(Captcha c)
453        {
454            _captcha = c;
455            _date = new Date();
456            _valid = true;
457        }
458
459        /**
460         * Determine if the validity date has not expired
461         * @return true if the captcha is still valid
462         */
463        public boolean isValid()
464        {
465            if (!_valid)
466            {
467                return false;
468            }
469            
470            Calendar validity = new GregorianCalendar();
471            validity.add(Calendar.MINUTE, -20);
472            
473            return _date.after(validity.getTime()); 
474        }
475        
476        /**
477         * Get the wrapper captcha
478         * @return captcha
479         */
480        public Captcha getCaptcha()
481        {
482            return _captcha;
483        }
484        
485        /**
486         * Mark the captcha as invalid 
487         */
488        public void invalidate()
489        {
490            _valid = false;
491        }
492    }
493}