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.lang3.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}