Skip to content

Commit

Permalink
Added captcha feature toggle with difficulty setting.
Browse files Browse the repository at this point in the history
  • Loading branch information
ivanmrsulja committed Dec 15, 2023
1 parent 4233deb commit 34842c7
Show file tree
Hide file tree
Showing 6 changed files with 289 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
Expand All @@ -17,6 +19,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
import net.logicsquad.nanocaptcha.image.ImageCaptcha;
import net.logicsquad.nanocaptcha.image.backgrounds.GradiatedBackgroundProducer;
import net.logicsquad.nanocaptcha.image.filter.FishEyeImageFilter;
Expand Down Expand Up @@ -57,10 +60,12 @@ public class CaptchaServiceBean {
* Validates a reCAPTCHA response using Google's reCAPTCHA API.
*
* @param recaptchaResponse The reCAPTCHA response to validate.
* @param secretKey The secret key used for Google ReCaptcha validation.
* @return True if the reCAPTCHA response is valid, false otherwise.
*/
public static boolean validateReCaptcha(String recaptchaResponse, String secretKey) {
public static boolean validateReCaptcha(String recaptchaResponse) {
String secretKey =
Objects.requireNonNull(ConfigurationProperties.getInstance().getProperty("recaptcha.secretKey"),
"You have to provide a secret key through configuration file.");
String verificationUrl =
"https://www.google.com/recaptcha/api/siteverify?secret=" + secretKey + "&response=" + recaptchaResponse;

Expand Down Expand Up @@ -88,16 +93,23 @@ public static boolean validateReCaptcha(String recaptchaResponse, String secretK
* @throws IOException If an error occurs during image conversion.
*/
public static CaptchaBundle generateChallenge() throws IOException {
ImageCaptcha imageCaptcha =
new ImageCaptcha.Builder(200, 75)
.addContent(random.nextInt(2) + 5)
.addBackground(new GradiatedBackgroundProducer())
String difficulty = getCaptchaDifficulty();
ImageCaptcha.Builder imageCaptchaBuilder = new ImageCaptcha.Builder(200, 75)
.addContent(random.nextInt(2) + 5)
.addBackground(new GradiatedBackgroundProducer())
.addNoise(new StraightLineNoiseProducer(getRandomColor(), 2))
.addFilter(new StretchImageFilter())
.addBorder();

if (difficulty.equals("hard")) {
imageCaptchaBuilder
.addNoise(new CurvedLineNoiseProducer(getRandomColor(), 2f))
.addNoise(new StraightLineNoiseProducer(getRandomColor(), 2))
.addFilter(new StretchImageFilter())
.addFilter(new FishEyeImageFilter())
.addBorder()
.build();
}

ImageCaptcha imageCaptcha = imageCaptchaBuilder.build();
return new CaptchaBundle(convertToBase64(imageCaptcha.getImage()), imageCaptcha.getContent(),
UUID.randomUUID().toString());
}
Expand Down Expand Up @@ -145,6 +157,86 @@ private static String convertToBase64(BufferedImage image) throws IOException {
return Base64.getEncoder().encodeToString(imageBytes);
}

/**
* Retrieves the configured captcha implementation based on the application's configuration properties.
* If captcha functionality is disabled, returns "NONE." If the captcha implementation is not specified,
* defaults to "NANOCAPTCHA."
*
* @return The selected captcha implementation ("NANOCAPTCHA," "RECAPTCHA," or "NONE").
*/
public static String getCaptchaImpl() {
if (!Boolean.parseBoolean(ConfigurationProperties.getInstance().getProperty("captcha.enabled"))) {
return "NONE";
}

String captchaImpl =
ConfigurationProperties.getInstance().getProperty("captcha.implementation");
if (captchaImpl == null) {
captchaImpl = "NANOCAPTCHA";
}

return captchaImpl;
}

/**
* Adds captcha-related fields to the given page context map. The specific fields added depend on the
* configured captcha implementation.
*
* If the captcha implementation is "RECAPTCHA," the "siteKey" field is added to the context. If the
* implementation is "NANOCAPTCHA" or "NONE," a captcha challenge is generated, and "challenge" and
* "challengeId" fields are added to the context. Additionally, the "captchaToUse" field is added
* to the context, indicating the selected captcha implementation.
*
* @param context The page context map to which captcha-related fields are added.
* @throws IOException If there is an IO error during captcha challenge generation.
*/
public static void addCaptchaRelatedFieldsToPageContext(Map<String, Object> context) throws IOException {
String captchaImpl = getCaptchaImpl();

if (captchaImpl.equals("RECAPTCHA")) {
context.put("siteKey",
Objects.requireNonNull(ConfigurationProperties.getInstance().getProperty("recaptcha.siteKey"),
"You have to provide a site key through configuration file."));
} else {
CaptchaBundle captchaChallenge = generateChallenge();
CaptchaServiceBean.getCaptchaChallenges().put(captchaChallenge.getCaptchaId(), captchaChallenge);

context.put("challenge", captchaChallenge.getB64Image());
context.put("challengeId", captchaChallenge.getCaptchaId());
}

context.put("captchaToUse", captchaImpl);
}

/**
* Validates a user's captcha input against the stored captcha challenge.
*
* @param captchaInput The user's input for the captcha challenge.
* @param challengeId The unique identifier for the captcha challenge.
* @return {@code true} if the captcha input is valid, {@code false} otherwise.
*/
public static boolean validateCaptcha(String captchaInput, String challengeId) {
String captchaImpl = getCaptchaImpl();

switch (captchaImpl) {
case "RECAPTCHA":
if (CaptchaServiceBean.validateReCaptcha(captchaInput)) {
return true;
}
break;
case "NANOCAPTCHA":
Optional<CaptchaBundle> optionalChallenge = CaptchaServiceBean.getChallenge(challengeId);
if (optionalChallenge.isPresent() && optionalChallenge.get().getCode().equals(captchaInput)) {
return true;
}
break;
case "NONE":
return true;
}

return false;
}

/**
* Generates a random Color object.
*
Expand All @@ -156,4 +248,18 @@ private static Color getRandomColor() {
int b = random.nextInt(256);
return new Color(r, g, b);
}

/**
* Retrieves the configured difficulty level for generating captchas.
* If the difficulty level is not specified or is not "hard", the default difficulty is set to "easy".
*
* @return The difficulty level for captcha generation ("easy" or "hard").
*/
private static String getCaptchaDifficulty() {
String difficulty = ConfigurationProperties.getInstance().getProperty("nanocaptcha.difficulty");
if (difficulty == null || !difficulty.equals("hard")) {
difficulty = "easy";
}
return difficulty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,10 @@ else if (StringUtils.isBlank(appBean.getContactMail())) {
}

else {
String captchaImpl =
ConfigurationProperties.getInstance().getProperty("captcha.implementation");
if (captchaImpl == null) {
captchaImpl = "";
}

if (captchaImpl.equals("RECAPTCHA")) {
body.put("siteKey",
Objects.requireNonNull(ConfigurationProperties.getInstance().getProperty("recaptcha.siteKey"),
"You have to provide a site key through configuration file."));
} else {
CaptchaBundle captchaChallenge = CaptchaServiceBean.generateChallenge();
CaptchaServiceBean.getCaptchaChallenges().put(captchaChallenge.getCaptchaId(), captchaChallenge);

body.put("challenge", captchaChallenge.getB64Image());
body.put("challengeId", captchaChallenge.getCaptchaId());
}
CaptchaServiceBean.addCaptchaRelatedFieldsToPageContext(body);

body.put("contextPath", vreq.getContextPath());
body.put("formAction", "submitFeedback");
body.put("captchaToUse", captchaImpl);

if (vreq.getHeader("Referer") == null) {
vreq.getSession().setAttribute("contactFormReferer","none");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,7 @@ protected ResponseValues processRequest(VitroRequest vreq) throws IOException {
return errorNoRecipients();
}

captchaImpl = ConfigurationProperties.getInstance().getProperty("captcha.implementation");
if (captchaImpl == null) {
captchaImpl = "";
}
captchaImpl = CaptchaServiceBean.getCaptchaImpl();

String webusername = nonNullAndTrim(vreq, WEB_USERNAME_PARAM);
String webuseremail = nonNullAndTrim(vreq, WEB_USEREMAIL_PARAM);
Expand All @@ -97,7 +94,7 @@ protected ResponseValues processRequest(VitroRequest vreq) throws IOException {
case "RECAPTCHA":
captchaInput = nonNullAndTrim(vreq, "g-recaptcha-response");
break;
case "DEFAULT":
case "NANOCAPTCHA":
default:
captchaInput = nonNullAndTrim(vreq, "userSolution");
captchaId = nonNullAndTrim(vreq, "challengeId");
Expand All @@ -106,11 +103,7 @@ protected ResponseValues processRequest(VitroRequest vreq) throws IOException {
String errorMsg = validateInput(webusername, webuseremail, comments, captchaInput, captchaId, vreq);

if (errorMsg != null) {
String siteKey =
Objects.requireNonNull(ConfigurationProperties.getInstance().getProperty("recaptcha.siteKey"),
"You have to provide a site key through configuration file.");
return errorParametersNotValid(errorMsg, webusername, webuseremail, comments, vreq.getContextPath(),
siteKey);
return errorParametersNotValid(errorMsg, webusername, webuseremail, comments, vreq.getContextPath());
}

String spamReason = checkForSpam(comments, formType);
Expand Down Expand Up @@ -345,27 +338,14 @@ private String validateInput(String webusername, String webuseremail,
return i18nBundle.text("comments_empty");
}

if (captchaInput.isEmpty()) {
if (!captchaImpl.equals("NONE") && captchaInput.isEmpty()) {
return i18nBundle.text("captcha_user_sol_empty");
}

switch (captchaImpl) {
case "RECAPTCHA":
String secretKey =
Objects.requireNonNull(ConfigurationProperties.getInstance().getProperty("recaptcha.secretKey"),
"You have to provide a secret key through configuration file.");
if (CaptchaServiceBean.validateReCaptcha(captchaInput, secretKey)) {
return null;
}
case "DEFAULT":
default:
Optional<CaptchaBundle> optionalChallenge = CaptchaServiceBean.getChallenge(challengeId);
if (optionalChallenge.isPresent() && optionalChallenge.get().getCode().equals(captchaInput)) {
return null;
}
if (CaptchaServiceBean.validateCaptcha(captchaInput, challengeId)) {
return null;
}


return i18nBundle.text("captcha_user_sol_invalid");
}

Expand Down Expand Up @@ -423,9 +403,9 @@ private ResponseValues errorNoRecipients() {
}

private ResponseValues errorParametersNotValid(String errorMsg, String webusername, String webuseremail,
String comments, String contextPath, String siteKey)
String comments, String contextPath)
throws IOException {
Map<String, Object> body = new HashMap<String, Object>();
Map<String, Object> body = new HashMap<>();
body.put("errorMessage", errorMsg);
body.put("formAction", "submitFeedback");
body.put("webusername", webusername);
Expand All @@ -434,14 +414,7 @@ private ResponseValues errorParametersNotValid(String errorMsg, String webuserna
body.put("captchaToUse", captchaImpl);
body.put("contextPath", contextPath);

if (!captchaImpl.equals("RECAPTCHA")) {
CaptchaBundle captchaChallenge = CaptchaServiceBean.generateChallenge();
CaptchaServiceBean.getCaptchaChallenges().put(captchaChallenge.getCaptchaId(), captchaChallenge);
body.put("challenge", captchaChallenge.getB64Image());
body.put("challengeId", captchaChallenge.getCaptchaId());
} else {
body.put("siteKey", siteKey);
}
CaptchaServiceBean.addCaptchaRelatedFieldsToPageContext(body);

return new TemplateResponseValues(TEMPLATE_FORM, body);
}
Expand Down
Loading

0 comments on commit 34842c7

Please sign in to comment.