Skip to content

Commit

Permalink
Improved implementation of CAPTCHA for in-band registration #1510
Browse files Browse the repository at this point in the history
  • Loading branch information
hantu85 committed Nov 19, 2023
1 parent c95a259 commit a8345a4
Show file tree
Hide file tree
Showing 2 changed files with 119 additions and 11 deletions.
117 changes: 110 additions & 7 deletions src/main/java/tigase/xmpp/impl/CaptchaProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,53 @@
package tigase.xmpp.impl;

import tigase.kernel.beans.Bean;
import tigase.util.Base64;
import tigase.xmpp.XMPPResourceConnection;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Optional;
import java.util.Random;

@Bean(name = "CaptchaProvider", parent = JabberIqRegister.class, active = true)
public class CaptchaProvider {

private Random random = new SecureRandom();

public CaptchaItem generateCaptcha() {
return new SimpleTextCaptcha(1 + random.nextInt(31), 1 + random.nextInt(31));
public CaptchaItem generateCaptcha(XMPPResourceConnection connection) {
return new SimpleTextCaptcha(random, connection);
}

public CaptchaItem getCaptchaByID(String id) {
if (id == null) {
return null;
}

try {
String[] parts = id.split("\\.");
String type = parts[0];
if (!"simple-text".equals(type)) {
return null;
}

return new SimpleTextCaptcha(parts);
} catch (Throwable ex) {
// could not parse captcha data
return null;
}
}

public interface CaptchaItem {

String getID();

String getCaptchaRequest(XMPPResourceConnection session);

int getErrorCounter();
Expand All @@ -47,14 +78,70 @@ public interface CaptchaItem {
private class SimpleTextCaptcha
implements CaptchaItem {

private static final Duration TIMEOUT = Duration.ofMinutes(5);
private static final int NONCE_LENGTH = 16;

private final int a;

private final int b;
private final int result;
private final byte[] nonce;
private final long time;
private final byte[] hmac;
private int errorCounter;
private final Duration timeout = TIMEOUT;

public static byte[] calculateHMac(byte[] data, String key) throws NoSuchAlgorithmException, InvalidKeyException {
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKeySpec);
return mac.doFinal(data);
}

public static String getSecret(XMPPResourceConnection connection) {
return Optional.ofNullable(connection.getDomain().getS2sSecret())
.orElse(connection.getDomainAsJID().toString());
}

SimpleTextCaptcha(Random random, XMPPResourceConnection connection) {
this.a = 1 + random.nextInt(31);
this.b = 1 + random.nextInt(31);
this.result = a + b;
this.nonce = new byte[NONCE_LENGTH];
random.nextBytes(this.nonce);
this.time = System.currentTimeMillis();

try {
this.hmac = calculateHMac((getPrefix() + "." + result).getBytes(StandardCharsets.UTF_8),
getSecret(connection));
} catch (Throwable ex) {
throw new RuntimeException("Could not generate HMAC", ex);
}
}

SimpleTextCaptcha(String[] parts) {
this.nonce = new byte[NONCE_LENGTH];
ByteBuffer tmp = ByteBuffer.wrap(Base64.decode(parts[1]));
tmp.get(nonce);
this.a = tmp.getInt();
this.b = tmp.getInt();
this.time = tmp.getLong();
this.result = a + b;
this.hmac = Base64.decode(parts[2]);
}

SimpleTextCaptcha(int a, int b) {
this.a = a;
this.b = b;
@Override
public String getID() {
return getPrefix() + "." + Base64.encode(hmac);
}

protected String getPrefix() {
ByteBuffer tmp = ByteBuffer.allocate(nonce.length + 4 + 4 + 8);
tmp.put(nonce);
tmp.putInt(a);
tmp.putInt(b);
tmp.putLong(time);
return "simple-text" + "." + Base64.encode(tmp.array());
}

@Override
Expand All @@ -77,9 +164,25 @@ public boolean isResponseValid(XMPPResourceConnection session, String response)
if (response == null) {
return false;
}
final int v = a + b;
return response.trim().equals(String.valueOf(v));
try {
String responseResult = response.trim();
// check if response matches
if (!String.valueOf(this.result).equals(responseResult)) {
return false;
}
// check if it is not expired
if (time > System.currentTimeMillis() || (System.currentTimeMillis() - time) > timeout.toMillis()) {
return false;
}
// then check if anyone tampered with token
byte[] calculated = calculateHMac((getPrefix() + "." + responseResult).getBytes(StandardCharsets.UTF_8),
getSecret(session));
return Arrays.equals(hmac, calculated);
} catch (Throwable ex) {
return false;
}
}

}

}
13 changes: 9 additions & 4 deletions src/main/java/tigase/xmpp/impl/JabberIqRegister.java
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ public Element[] supDiscoFeatures(XMPPResourceConnection session) {
if (log.isLoggable(Level.FINEST) && (session != null)) {
log.finest("VHostItem: " + session.getDomain());
}
if ((session != null) && session.getDomain().isRegisterEnabled()) {
if ((session != null) && ((!session.isTlsRequired()) || session.isEncrypted()) && session.getDomain().isRegisterEnabled()) {
return DISCO_FEATURES;
} else {
return null;
Expand Down Expand Up @@ -691,7 +691,9 @@ private void validateSignedForm(Packet packet, XMPPResourceConnection session, F

private void validatCapchaForm(XMPPResourceConnection session, Form form) throws XMPPProcessorException {
CaptchaItem captcha = (CaptchaItem) session.getSessionData("jabber:iq:register:captcha");

if (captcha == null) {
captcha = captchaProvider.getCaptchaByID(form.getAsString("captcha-id"));
}
if (captcha == null) {
log.finest("CAPTCHA is required");
throw new XMPPProcessorException(Authorization.BAD_REQUEST,
Expand All @@ -718,9 +720,12 @@ private Element prepareCaptchaRegistrationForm(final XMPPResourceConnection sess
query.addChild(new Element("instructions", (emailRequired ? INSTRUCTION_EMAIL_REQUIRED_DEF : INSTRUCTION_DEF)));
Form form = prepareGenericRegistrationForm();

CaptchaItem captcha = captchaProvider.generateCaptcha();
CaptchaItem captcha = captchaProvider.generateCaptcha(session);
session.putSessionData("jabber:iq:register:captcha", captcha);
Field field = Field.fieldTextSingle("captcha", "", captcha.getCaptchaRequest(session));
Field field = Field.fieldHidden("captcha-id", captcha.getID());
field.setRequired(true);
form.addField(field);
field = Field.fieldTextSingle("captcha", "", captcha.getCaptchaRequest(session));
field.setRequired(true);
form.addField(field);

Expand Down

0 comments on commit a8345a4

Please sign in to comment.