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}