feat: 增强版验证码全套实现 — 16种类型生成器/加密/风控/站点管理/平台后端/前端SDK/UI

- 核心新增: ICON_CLICK(PNG图标方案,预渲染资源)/SCRATCH/JIGSAW/CURVE_SLIDER(V1-V3)/ANGLE/CURVE_DRAW/WORD_ORDER_CLICK/PROOF_OF_WORK 等生成器
- 图标点选: classpath PNG 加载替代运行时字体渲染(Linux容器无emoji字体),提示条图作为 templateImage 返回前端
- 新增模块: crypto(AES+RSA)/obfuscator(背景乱序/噪声/扭曲)/risk(风控/IP黑名单/限流)/site(站点管理)/ml(轨迹规则引擎)
- 平台后端: 站点管理/验证码API(generate/verify/secondary-verify)/统计/ML轨迹学习
- 前端SDK: TPCaptcha兼容,支持全部新型号渲染与交互
- 工具: tools/icon-render 图标预渲染工具
This commit is contained in:
abcv7
2026-08-26 09:58:15 +08:00
parent d56958727c
commit dbe9b58b0c
115 changed files with 10365 additions and 36 deletions
@@ -0,0 +1,71 @@
package cloud.tianai.captcha.crypto;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AesEncryptor {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private final SecureRandom secureRandom = new SecureRandom();
private final SecretKeySpec key;
public AesEncryptor(byte[] keyBytes) {
if (keyBytes.length != 32) {
throw new IllegalArgumentException("AES-256 key must be 32 bytes, got " + keyBytes.length);
}
this.key = new SecretKeySpec(keyBytes, "AES");
}
public AesEncryptor(String base64Key) {
this(Base64.getDecoder().decode(base64Key));
}
public String encrypt(String plaintext) {
try {
byte[] iv = new byte[GCM_IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new CryptoException("AES encryption failed", e);
}
}
public String decrypt(String encrypted) {
try {
byte[] combined = Base64.getDecoder().decode(encrypted);
if (combined.length < GCM_IV_LENGTH) {
throw new CryptoException("Encrypted data too short");
}
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH];
System.arraycopy(combined, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
throw new CryptoException("AES decryption failed", e);
}
}
public static byte[] generateKey() {
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
return key;
}
}
@@ -0,0 +1,12 @@
package cloud.tianai.captcha.crypto;
public class CryptoException extends RuntimeException {
public CryptoException(String message) {
super(message);
}
public CryptoException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,115 @@
package cloud.tianai.captcha.crypto;
import java.security.KeyPair;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class CryptoService {
private final AesEncryptor aesEncryptor;
private final RsaEncryptor rsaEncryptor;
private final Signer signer;
private final String secretKey;
public CryptoService(byte[] aesKey, KeyPair rsaKeyPair, String secretKey) {
this.aesEncryptor = new AesEncryptor(aesKey);
try {
this.rsaEncryptor = new RsaEncryptor(
RsaEncryptor.publicKeyToBase64(rsaKeyPair.getPublic()),
RsaEncryptor.privateKeyToBase64(rsaKeyPair.getPrivate())
);
} catch (Exception e) {
throw new CryptoException("Failed to initialize RSA encryptor", e);
}
this.signer = new Signer();
this.secretKey = secretKey;
}
public CryptoService(String base64AesKey, String base64PublicKey, String base64PrivateKey, String secretKey) {
this.aesEncryptor = new AesEncryptor(base64AesKey);
try {
this.rsaEncryptor = new RsaEncryptor(base64PublicKey, base64PrivateKey);
} catch (Exception e) {
throw new CryptoException("Failed to initialize RSA encryptor", e);
}
this.signer = new Signer();
this.secretKey = secretKey;
}
public EncryptedPayload encrypt(String plaintext) {
String nonce = signer.generateNonce();
long timestamp = System.currentTimeMillis();
String aesEncrypted = aesEncryptor.encrypt(plaintext);
String signature = signer.signWithTimestamp(aesEncrypted + "|" + nonce, secretKey, timestamp);
EncryptedPayload payload = new EncryptedPayload();
payload.data = aesEncrypted;
payload.nonce = nonce;
payload.timestamp = timestamp;
payload.signature = signature;
return payload;
}
public String decrypt(EncryptedPayload payload) {
if (!signer.verifyWithTimestamp(
payload.data + "|" + payload.nonce,
secretKey,
payload.signature,
payload.timestamp,
300000
)) {
throw new CryptoException("Signature verification failed or timestamp expired");
}
return aesEncryptor.decrypt(payload.data);
}
public String encryptAesKeyForTransport(byte[] aesKey) {
return rsaEncryptor.encrypt(Base64.getEncoder().encodeToString(aesKey));
}
public String decryptAesKeyFromTransport(String encryptedAesKey) {
return rsaEncryptor.decrypt(encryptedAesKey);
}
public String getPublicKeyBase64() {
return RsaEncryptor.publicKeyToBase64(rsaEncryptor.getPublicKey());
}
public static CryptoService generate() throws Exception {
byte[] aesKey = AesEncryptor.generateKey();
KeyPair rsaKeyPair = RsaEncryptor.generateKeyPair();
byte[] secretKeyBytes = new byte[32];
new java.security.SecureRandom().nextBytes(secretKeyBytes);
String secretKey = Base64.getEncoder().encodeToString(secretKeyBytes);
return new CryptoService(aesKey, rsaKeyPair, secretKey);
}
public static class EncryptedPayload {
public String data;
public String nonce;
public long timestamp;
public String signature;
public Map<String, Object> toMap() {
Map<String, Object> map = new HashMap<>();
map.put("data", data);
map.put("nonce", nonce);
map.put("ts", timestamp);
map.put("sig", signature);
return map;
}
public static EncryptedPayload fromMap(Map<String, Object> map) {
EncryptedPayload payload = new EncryptedPayload();
payload.data = (String) map.get("data");
payload.nonce = (String) map.get("nonce");
payload.timestamp = map.get("ts") instanceof Number
? ((Number) map.get("ts")).longValue()
: Long.parseLong(String.valueOf(map.get("ts")));
payload.signature = (String) map.get("sig");
return payload;
}
}
}
@@ -0,0 +1,95 @@
package cloud.tianai.captcha.crypto;
import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import java.util.Base64;
public class RsaEncryptor {
private static final String ALGORITHM = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
private static final int KEY_SIZE = 4096;
private static final int MAX_ENCRYPT_BLOCK = 446;
private final PublicKey publicKey;
private final PrivateKey privateKey;
public PublicKey getPublicKey() {
return publicKey;
}
public PrivateKey getPrivateKey() {
return privateKey;
}
public RsaEncryptor(PublicKey publicKey, PrivateKey privateKey) {
this.publicKey = publicKey;
this.privateKey = privateKey;
}
public RsaEncryptor(String base64PublicKey, String base64PrivateKey) throws Exception {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
this.publicKey = base64PublicKey != null
? keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(base64PublicKey)))
: null;
this.privateKey = base64PrivateKey != null
? keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64PrivateKey)))
: null;
}
public String encrypt(String plaintext) {
if (publicKey == null) {
throw new CryptoException("Public key not available for encryption");
}
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepSpec);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new CryptoException("RSA encryption failed", e);
}
}
public String decrypt(String encrypted) {
if (privateKey == null) {
throw new CryptoException("Private key not available for decryption");
}
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepSpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypted));
return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
throw new CryptoException("RSA decryption failed", e);
}
}
public static KeyPair generateKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(KEY_SIZE);
return generator.generateKeyPair();
}
public static String publicKeyToBase64(PublicKey key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
public static String privateKeyToBase64(PrivateKey key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
}
@@ -0,0 +1,57 @@
package cloud.tianai.captcha.crypto;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class Signer {
private static final String ALGORITHM = "SHA-256";
private final SecureRandom secureRandom = new SecureRandom();
public String sign(String data, String secretKey) {
try {
MessageDigest digest = MessageDigest.getInstance(ALGORITHM);
String payload = secretKey + data + secretKey;
byte[] hash = digest.digest(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new CryptoException("Signing failed", e);
}
}
public boolean verify(String data, String secretKey, String signature) {
String expected = sign(data, secretKey);
return constantTimeEquals(expected, signature);
}
public String signWithTimestamp(String data, String secretKey, long timestamp) {
return sign(data + "|" + timestamp, secretKey);
}
public boolean verifyWithTimestamp(String data, String secretKey, String signature, long timestamp, long toleranceMs) {
long now = System.currentTimeMillis();
if (Math.abs(now - timestamp) > toleranceMs) {
return false;
}
return verify(data + "|" + timestamp, secretKey, signature);
}
public String generateNonce() {
byte[] nonce = new byte[16];
secureRandom.nextBytes(nonce);
return Base64.getUrlEncoder().withoutPadding().encodeToString(nonce);
}
private boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) {
return false;
}
int result = 0;
for (int i = 0; i < a.length(); i++) {
result |= a.charAt(i) ^ b.charAt(i);
}
return result == 0;
}
}
@@ -0,0 +1,116 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
public class AngleRotateImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
super(rm);
}
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
super(rm);
setImageTransform(transform);
}
public AngleRotateImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
super(rm);
setImageTransform(transform);
setInterceptor(interceptor);
}
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
int centerX = background.getWidth() / 2;
int centerY = background.getHeight() / 2;
int targetAngle = randomInt(30, 330);
double radians = Math.toRadians(targetAngle);
BufferedImage rotatedActive = rotateImage(activeTemplate, -radians);
int templateSize = activeTemplate.getWidth();
int drawX = centerX - templateSize / 2;
int drawY = centerY - templateSize / 2;
CaptchaImageUtils.overlayImage(background, fixedTemplate, drawX, drawY);
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(templateSize, background.getHeight());
CaptchaImageUtils.overlayImage(matrixTemplate, rotatedActive, 0, drawY);
captchaExchange.setBackgroundImage(background);
captchaExchange.setTemplateImage(matrixTemplate);
captchaExchange.setTemplateResource(templateResource);
captchaExchange.setResourceImage(resourceImage);
captchaExchange.setTransferData(new AngleData(targetAngle));
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
BufferedImage templateImage = captchaExchange.getTemplateImage();
Resource resourceImage = captchaExchange.getResourceImage();
ResourceMap templateResource = captchaExchange.getTemplateResource();
CustomData customData = captchaExchange.getCustomData();
AngleData angleData = (AngleData) captchaExchange.getTransferData();
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, templateImage, resourceImage, templateResource, customData);
RotateImageCaptchaInfo info = RotateImageCaptchaInfo.of((double) angleData.angle, 0,
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
resourceImage.getTag(), templateResource.getTag(),
backgroundImage.getWidth(), backgroundImage.getHeight(),
templateImage.getWidth(), templateImage.getHeight());
info.setData(customData);
return info;
}
private BufferedImage rotateImage(BufferedImage image, double radians) {
int w = image.getWidth();
int h = image.getHeight();
int newW = (int) Math.ceil(Math.abs(w * Math.cos(radians)) + Math.abs(h * Math.sin(radians)));
int newH = (int) Math.ceil(Math.abs(h * Math.cos(radians)) + Math.abs(w * Math.sin(radians)));
BufferedImage result = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = result.createGraphics();
try {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
AffineTransform at = new AffineTransform();
at.translate((double) newW / 2, (double) newH / 2);
at.rotate(radians);
at.translate((double) -w / 2, (double) -h / 2);
g2d.drawRenderedImage(image, at);
} finally {
g2d.dispose();
}
return result;
}
public static class AngleData {
public int angle;
public AngleData(int angle) { this.angle = angle; }
}
}
@@ -0,0 +1,117 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import java.awt.*;
import java.awt.geom.CubicCurve2D;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public class CurveDrawImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public CurveDrawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
int width = background.getWidth();
int height = background.getHeight();
ThreadLocalRandom random = ThreadLocalRandom.current();
double x0 = random.nextDouble(width * 0.05, width * 0.15);
double y0 = random.nextDouble(height * 0.3, height * 0.7);
double x3 = random.nextDouble(width * 0.85, width * 0.95);
double y3 = random.nextDouble(height * 0.3, height * 0.7);
double x1 = random.nextDouble(width * 0.25, width * 0.45);
double y1 = random.nextDouble(height * 0.1, height * 0.9);
double x2 = random.nextDouble(width * 0.55, width * 0.75);
double y2 = random.nextDouble(height * 0.1, height * 0.9);
Graphics2D g2d = background.createGraphics();
try {
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setColor(new Color(50, 50, 200, 180));
g2d.setStroke(new BasicStroke(3));
CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3);
g2d.draw(curve);
g2d.setColor(new Color(200, 50, 50, 200));
g2d.fillOval((int) x0 - 5, (int) y0 - 5, 10, 10);
g2d.fillOval((int) x3 - 5, (int) y3 - 5, 10, 10);
} finally {
g2d.dispose();
}
captchaExchange.setBackgroundImage(background);
captchaExchange.setTemplateImage(null);
captchaExchange.setResourceImage(resourceImage);
CurveDrawData curveData = new CurveDrawData(x0, y0, x1, y1, x2, y2, x3, y3);
// 在曲线上均匀采样点用于验证
List<Point2D.Double> samplePoints = new ArrayList<>();
int sampleCount = 10;
for (int i = 0; i < sampleCount; i++) {
double t = (double) i / (sampleCount - 1);
double sx = cubicBezier(t, x0, x1, x2, x3);
double sy = cubicBezier(t, y0, y1, y2, y3);
samplePoints.add(new Point2D.Double(sx, sy));
}
curveData.setSamplePoints(samplePoints);
captchaExchange.setTransferData(curveData);
}
private static double cubicBezier(double t, double p0, double p1, double p2, double p3) {
double oneMinusT = 1.0 - t;
return oneMinusT * oneMinusT * oneMinusT * p0 +
3.0 * oneMinusT * oneMinusT * t * p1 +
3.0 * oneMinusT * t * t * p2 +
t * t * t * p3;
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage bg = captchaExchange.getBackgroundImage();
Resource resImg = captchaExchange.getResourceImage();
CustomData customData = captchaExchange.getCustomData();
ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData);
ImageCaptchaInfo info = ImageCaptchaInfo.of(
transform.getBackgroundImageUrl(), null,
resImg.getTag(), null,
bg.getWidth(), bg.getHeight(), 0, 0,
0, "CURVE_DRAW");
// 将 CurveDrawData 设置到 customData.expand 中
CurveDrawData curveData = (CurveDrawData) captchaExchange.getTransferData();
if (customData == null) {
customData = new CustomData();
}
customData.setExpand(curveData);
info.setData(customData);
return info;
}
public static class CurveDrawData {
public double x0, y0, x1, y1, x2, y2, x3, y3;
public List<Point2D.Double> samplePoints;
public CurveDrawData(double x0, double y0, double x1, double y1, double x2, double y2, double x3, double y3) {
this.x0 = x0; this.y0 = y0; this.x1 = x1; this.y1 = y1;
this.x2 = x2; this.y2 = y2; this.x3 = x3; this.y3 = y3;
}
public List<Point2D.Double> getSamplePoints() { return samplePoints; }
public void setSamplePoints(List<Point2D.Double> samplePoints) { this.samplePoints = samplePoints; }
}
}
@@ -0,0 +1,124 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import java.awt.*;
import java.awt.geom.CubicCurve2D;
import java.awt.geom.FlatteningPathIterator;
import java.awt.geom.PathIterator;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public class CurveSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png";
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public CurveSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
BufferedImage maskTemplate = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME).orElse(fixedTemplate);
int bgWidth = background.getWidth();
int bgHeight = background.getHeight();
int tplWidth = fixedTemplate.getWidth();
ThreadLocalRandom random = ThreadLocalRandom.current();
double x0 = random.nextDouble(0, bgWidth * 0.1);
double y0 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7);
double x3 = random.nextDouble(bgWidth * 0.85, bgWidth * 0.95);
double y3 = random.nextDouble(bgHeight * 0.3, bgHeight * 0.7);
double x1 = random.nextDouble(bgWidth * 0.25, bgWidth * 0.45);
double y1 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9);
double x2 = random.nextDouble(bgWidth * 0.55, bgWidth * 0.75);
double y2 = random.nextDouble(bgHeight * 0.1, bgHeight * 0.9);
CubicCurve2D curve = new CubicCurve2D.Double(x0, y0, x1, y1, x2, y2, x3, y3);
List<Point> pathPoints = sampleCurve(curve, bgWidth);
double targetRatio = random.nextDouble(0.35, 0.75);
int targetIndex = (int) (pathPoints.size() * targetRatio);
Point targetPoint = pathPoints.get(targetIndex);
int randomX = targetPoint.x;
int randomY = Math.max(0, Math.min(bgHeight - tplWidth, targetPoint.y - tplWidth / 2));
BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY);
CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY);
CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0);
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight());
CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY);
captchaExchange.setBackgroundImage(background);
captchaExchange.setTemplateImage(matrixTemplate);
captchaExchange.setTemplateResource(templateResource);
captchaExchange.setResourceImage(resourceImage);
captchaExchange.setTransferData(new CurveData(randomX, randomY, pathPoints));
}
@Override
public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage bg = captchaExchange.getBackgroundImage();
BufferedImage tpl = captchaExchange.getTemplateImage();
Resource resImg = captchaExchange.getResourceImage();
ResourceMap tplRes = captchaExchange.getTemplateResource();
CustomData customData = captchaExchange.getCustomData();
CurveData curveData = (CurveData) captchaExchange.getTransferData();
ImageTransformData transform = getImageTransform().transform(param, bg, tpl, resImg, tplRes, customData);
SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(curveData.x, curveData.y,
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
resImg.getTag(), tplRes.getTag(),
bg.getWidth(), bg.getHeight(), tpl.getWidth(), tpl.getHeight());
info.setData(customData);
return info;
}
private List<Point> sampleCurve(CubicCurve2D curve, int bgWidth) {
List<Point> points = new ArrayList<>();
PathIterator pi = curve.getPathIterator(null, 0.5);
FlatteningPathIterator fpi = new FlatteningPathIterator(pi, 0.5);
double[] coords = new double[6];
while (!fpi.isDone()) {
int type = fpi.currentSegment(coords);
if (type == PathIterator.SEG_LINETO || type == PathIterator.SEG_MOVETO) {
points.add(new Point((int) coords[0], (int) coords[1]));
}
fpi.next();
}
return points;
}
public static class CurveData {
public int x;
public int y;
public List<Point> pathPoints;
public CurveData(int x, int y, List<Point> pathPoints) {
this.x = x; this.y = y; this.pathPoints = pathPoints;
}
}
}
@@ -0,0 +1,12 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
public class CurveSliderV2ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator {
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); }
public CurveSliderV2ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); }
}
@@ -0,0 +1,12 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
public class CurveSliderV3ImageCaptchaGenerator extends CurveSliderImageCaptchaGenerator {
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm, t); }
public CurveSliderV3ImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm, t, i); }
}
@@ -0,0 +1,118 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.obfuscator.CompositeObfuscator;
import cloud.tianai.captcha.obfuscator.ImageObfuscator;
import cloud.tianai.captcha.obfuscator.ObfuscatorConfig;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Optional;
public class EnhancedSliderImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
public static String TEMPLATE_ACTIVE_IMAGE_NAME = "active.png";
public static String TEMPLATE_FIXED_IMAGE_NAME = "fixed.png";
public static String TEMPLATE_MASK_IMAGE_NAME = "mask.png";
public static String OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME = "obfuscate_" + TEMPLATE_FIXED_IMAGE_NAME;
private ImageObfuscator obfuscator = new CompositeObfuscator();
private ObfuscatorConfig obfuscatorConfig = ObfuscatorConfig.defaultConfig();
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
super(rm);
}
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
super(rm);
setImageTransform(transform);
}
public EnhancedSliderImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
super(rm);
setImageTransform(transform);
setInterceptor(interceptor);
}
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
Boolean obfuscate = param.getObfuscate();
ResourceMap templateResource = requiredRandomGetTemplate(param.getType(), param.getTemplateImageTag());
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
BufferedImage fixedTemplate = getTemplateImage(templateResource, TEMPLATE_FIXED_IMAGE_NAME);
BufferedImage activeTemplate = getTemplateImage(templateResource, TEMPLATE_ACTIVE_IMAGE_NAME);
BufferedImage maskTemplate = fixedTemplate;
Optional<BufferedImage> maskOpt = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME);
if (maskOpt.isPresent()) {
maskTemplate = maskOpt.get();
}
int randomX = randomInt(fixedTemplate.getWidth() + 5, background.getWidth() - fixedTemplate.getWidth() - 10);
int randomY = randomInt(background.getHeight() - fixedTemplate.getHeight());
BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, randomX, randomY);
CaptchaImageUtils.overlayImage(background, fixedTemplate, randomX, randomY);
if (obfuscate) {
Optional<BufferedImage> obfOpt = getTemplateImageOfOptional(templateResource, OBFUSCATE_TEMPLATE_FIXED_IMAGE_NAME);
BufferedImage obfImage = obfOpt.orElseGet(() -> new StandardSliderImageCaptchaGenerator(getImageResourceManager()) {}.createObfuscate(fixedTemplate));
int obfX = randomObfuscateX(randomX, fixedTemplate.getWidth(), background.getWidth());
CaptchaImageUtils.overlayImage(background, obfImage, obfX, randomY);
}
CaptchaImageUtils.overlayImage(cutImage, activeTemplate, 0, 0);
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(activeTemplate.getWidth(), background.getHeight());
CaptchaImageUtils.overlayImage(matrixTemplate, cutImage, 0, randomY);
if (obfuscate) {
background = obfuscator.obfuscate(background, obfuscatorConfig);
}
captchaExchange.setBackgroundImage(background);
captchaExchange.setTemplateImage(matrixTemplate);
captchaExchange.setTemplateResource(templateResource);
captchaExchange.setResourceImage(resourceImage);
captchaExchange.setTransferData(new Point(randomX, randomY));
}
@Override
public SliderImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
BufferedImage sliderImage = captchaExchange.getTemplateImage();
Resource resourceImage = captchaExchange.getResourceImage();
ResourceMap templateResource = captchaExchange.getTemplateResource();
CustomData customData = captchaExchange.getCustomData();
Point data = (Point) captchaExchange.getTransferData();
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, sliderImage, resourceImage, templateResource, customData);
SliderImageCaptchaInfo info = SliderImageCaptchaInfo.of(data.x, data.y,
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
resourceImage.getTag(), templateResource.getTag(),
backgroundImage.getWidth(), backgroundImage.getHeight(),
sliderImage.getWidth(), sliderImage.getHeight());
info.setData(customData);
return info;
}
protected int randomObfuscateX(int sliderX, int slWidth, int bgWidth) {
if (bgWidth / 2 > (sliderX + (slWidth / 2))) {
return randomInt(sliderX + slWidth, bgWidth - slWidth);
}
return randomInt(slWidth, sliderX - slWidth);
}
public void setObfuscator(ImageObfuscator obfuscator) { this.obfuscator = obfuscator; }
public void setObfuscatorConfig(ObfuscatorConfig config) { this.obfuscatorConfig = config; }
}
@@ -0,0 +1,175 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.common.constant.CommonConstant;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
/**
* 图标点选验证码。
* <p>
* 图标使用预渲染的 PNG 资源(classpath: META-INF/captcha-icons/*.png),
* 不依赖运行环境字体(Linux 容器通常没有 emoji 字体,直接字体渲染会得到豆腐块)。
* 图标资产由 tools/icon-render/RenderIcons.java 在开发机(有 Segoe UI Emoji 字体)预先生成,
* 新增/替换图标时在开发机重跑该工具即可。
*/
public class IconClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator {
/** 图标分类,每次随机取一类并从中挑 3 个。名称对应 META-INF/captcha-icons/&lt;name&gt;.png */
private static final List<String[]> ICON_CATEGORIES = List.of(
new String[]{"apple", "banana", "cherry", "grapes", "lemon", "orange", "strawberry", "watermelon"},
new String[]{"dog", "cat", "mouse", "hamster", "rabbit", "fox", "bear", "panda"},
new String[]{"soccer", "basketball", "tennis", "volleyball", "billiards", "pingpong", "trophy", "boxing"},
new String[]{"car", "taxi", "suv", "bus", "racecar", "police", "ambulance", "firetruck"},
new String[]{"phone", "laptop", "desktop", "printer", "keyboard", "computer-mouse", "cd", "camera"}
);
private static final String ICON_BASE_PATH = "META-INF/captcha-icons/";
/** 图标在背景图上的渲染尺寸 */
private static final int ICON_RENDER_SIZE = 48;
/** 提示条内单个图标尺寸 */
private static final int TIP_ICON_SIZE = 36;
/** 提示条内边距 */
private static final int TIP_PADDING = 8;
/** classpath PNG 加载缓存(icon name -> image),PNG 本身不可变,可安全共享 */
private static final Map<String, BufferedImage> ICON_CACHE = new ConcurrentHashMap<>(48);
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public IconClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
protected List<ResourceMap> randomGetClickImgTips(GenerateParam param) {
ThreadLocalRandom random = ThreadLocalRandom.current();
String[] category = ICON_CATEGORIES.get(random.nextInt(ICON_CATEGORIES.size()));
List<String> icons = new ArrayList<>(Arrays.asList(category));
Collections.shuffle(icons, random);
int count = Math.min(3, icons.size());
List<ResourceMap> result = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
ResourceMap map = new ResourceMap("default", 2);
Resource iconResource = new Resource("icon", icons.get(i), "default");
map.put(CommonConstant.IMAGE_CLICK_ICON, iconResource);
map.put(CommonConstant.IMAGE_TIP_ICON, iconResource);
result.add(map);
}
return result;
}
@Override
public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) {
BufferedImage source = loadIcon(tip.getData());
BufferedImage scaled = new BufferedImage(ICON_RENDER_SIZE, ICON_RENDER_SIZE, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(source, 0, 0, ICON_RENDER_SIZE, ICON_RENDER_SIZE, null);
} finally {
g.dispose();
}
ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper();
wrapper.setImage(scaled);
wrapper.setImageColor(Color.BLACK);
return wrapper;
}
@Override
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList) {
// 3 个图标全部参与校验,保持生成顺序(与提示条顺序一致)
return allCheckDefinitionList;
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
List<ClickImageCheckDefinition> checkList = (List<ClickImageCheckDefinition>) captchaExchange.getTransferData();
BufferedImage bgImage = captchaExchange.getBackgroundImage();
Resource resourceImage = captchaExchange.getResourceImage();
CustomData customData = captchaExchange.getCustomData();
// 提示条:按点击顺序横向拼接图标,作为 templateImage 返回给前端展示
BufferedImage tipImage = genTipImage(checkList);
ImageTransformData transform = getImageTransform().transform(captchaExchange.getParam(), bgImage, tipImage, resourceImage, checkList, customData);
ImageCaptchaInfo info = new ImageCaptchaInfo();
info.setBackgroundImage(transform.getBackgroundImageUrl());
info.setTemplateImage(transform.getTemplateImageUrl());
info.setBackgroundImageTag(resourceImage.getTag());
info.setBackgroundImageWidth(bgImage.getWidth());
info.setBackgroundImageHeight(bgImage.getHeight());
info.setTemplateImageWidth(tipImage.getWidth());
info.setTemplateImageHeight(tipImage.getHeight());
info.setType(CaptchaTypeConstant.ICON_CLICK);
customData.setExpand(checkList);
info.setData(customData);
return info;
}
/**
* 将参与校验的图标按点击顺序横向拼接为提示条图(白底圆角,保证任意背景上可读)。
*/
private BufferedImage genTipImage(List<ClickImageCheckDefinition> checkList) {
int n = checkList.size();
int width = TIP_PADDING + n * (TIP_ICON_SIZE + TIP_PADDING);
int height = TIP_ICON_SIZE + 2 * TIP_PADDING;
BufferedImage tip = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = tip.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(255, 255, 255, 235));
g.fillRoundRect(0, 0, width - 1, height - 1, 12, 12);
g.setColor(new Color(0, 0, 0, 60));
g.drawRoundRect(0, 0, width - 1, height - 1, 12, 12);
int x = TIP_PADDING;
for (ClickImageCheckDefinition def : checkList) {
BufferedImage icon = def.getTipImage() != null && def.getTipImage().getImage() != null
? def.getTipImage().getImage()
: loadIcon(def.getTip().getData());
g.drawImage(icon, x, TIP_PADDING, TIP_ICON_SIZE, TIP_ICON_SIZE, null);
x += TIP_ICON_SIZE + TIP_PADDING;
}
} finally {
g.dispose();
}
return tip;
}
private static BufferedImage loadIcon(String name) {
return ICON_CACHE.computeIfAbsent(name, n -> {
String path = ICON_BASE_PATH + n + ".png";
try (InputStream in = IconClickImageCaptchaGenerator.class.getClassLoader().getResourceAsStream(path)) {
if (in == null) {
throw new IllegalStateException("图标资源不存在: " + path);
}
BufferedImage img = ImageIO.read(in);
if (img == null) {
throw new IllegalStateException("图标资源解析失败: " + path);
}
return img;
} catch (IOException e) {
throw new IllegalStateException("图标资源读取失败: " + path, e);
}
});
}
}
@@ -0,0 +1,123 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public class JigsawImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
private int gridCols = 3;
private int gridRows = 2;
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public JigsawImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
int width = background.getWidth();
int height = background.getHeight();
int tileWidth = width / gridCols;
int tileHeight = height / gridRows;
int totalTiles = gridCols * gridRows;
List<Integer> positions = new ArrayList<>(totalTiles);
for (int i = 0; i < totalTiles; i++) positions.add(i);
List<Integer> shuffled = new ArrayList<>(positions);
Collections.shuffle(shuffled, ThreadLocalRandom.current());
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = result.createGraphics();
try {
for (int i = 0; i < totalTiles; i++) {
int srcRow = i / gridCols;
int srcCol = i % gridCols;
int dstRow = shuffled.get(i) / gridCols;
int dstCol = shuffled.get(i) % gridCols;
int srcX = srcCol * tileWidth;
int srcY = srcRow * tileHeight;
int dstX = dstCol * tileWidth;
int dstY = dstRow * tileHeight;
BufferedImage tile = background.getSubimage(
Math.min(srcX, width - tileWidth),
Math.min(srcY, height - tileHeight),
tileWidth, tileHeight);
g2d.drawImage(tile, dstX, dstY, null);
}
g2d.setColor(new Color(255, 255, 255, 80));
for (int c = 1; c < gridCols; c++) {
g2d.drawLine(c * tileWidth, 0, c * tileWidth, height);
}
for (int r = 1; r < gridRows; r++) {
g2d.drawLine(0, r * tileHeight, width, r * tileHeight);
}
} finally {
g2d.dispose();
}
List<Integer> restoreOrder = new ArrayList<>(totalTiles);
Integer[] restore = new Integer[totalTiles];
for (int i = 0; i < totalTiles; i++) {
restore[shuffled.get(i)] = i;
}
Collections.addAll(restoreOrder, restore);
captchaExchange.setBackgroundImage(result);
captchaExchange.setTemplateImage(null);
captchaExchange.setResourceImage(resourceImage);
captchaExchange.setTransferData(new JigsawData(restoreOrder, gridCols, gridRows));
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage bg = captchaExchange.getBackgroundImage();
Resource resImg = captchaExchange.getResourceImage();
CustomData customData = captchaExchange.getCustomData();
JigsawData data = (JigsawData) captchaExchange.getTransferData();
ImageTransformData transform = getImageTransform().transform(param, bg, null, resImg, null, customData);
ImageCaptchaInfo info = ImageCaptchaInfo.of(
transform.getBackgroundImageUrl(), null,
resImg.getTag(), null,
bg.getWidth(), bg.getHeight(), 0, 0,
0, "JIGSAW");
info.setData(customData);
// Store restore order as expand for validator
customData.expand = data.restoreOrder.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("");
// Also pass cols/rows to frontend via viewData
customData.putViewData("cols", data.cols);
customData.putViewData("rows", data.rows);
return info;
}
public void setGridCols(int cols) { this.gridCols = cols; }
public void setGridRows(int rows) { this.gridRows = rows; }
public static class JigsawData {
public List<Integer> restoreOrder;
public int cols;
public int rows;
public JigsawData(List<Integer> restoreOrder, int cols, int rows) {
this.restoreOrder = restoreOrder; this.cols = cols; this.rows = rows;
}
}
}
@@ -0,0 +1,74 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.concurrent.ThreadLocalRandom;
public class ProofOfWorkCaptchaGenerator extends AbstractImageCaptchaGenerator {
private int difficulty = 4;
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public ProofOfWorkCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
byte[] challenge = new byte[32];
new SecureRandom().nextBytes(challenge);
String challengeStr = Base64.getUrlEncoder().withoutPadding().encodeToString(challenge);
captchaExchange.setTransferData(new PoWData(challengeStr, difficulty));
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
PoWData powData = (PoWData) captchaExchange.getTransferData();
ImageCaptchaInfo info = new ImageCaptchaInfo();
info.setType("PROOF_OF_WORK");
info.setRandomX(powData.difficulty);
// Put data on the exchange's customData, not a new one
// (AbstractImageCaptchaGenerator.generateCaptchaImage overwrites info.data with exchange.customData)
CustomData customData = captchaExchange.getCustomData();
customData.putViewData("challenge", powData.challenge);
customData.putViewData("difficulty", powData.difficulty);
customData.expand = powData;
info.setData(customData);
return info;
}
public static boolean verifyProof(String challenge, int difficulty, String nonce) {
try {
String input = challenge + nonce;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
for (int i = 0; i < difficulty; i++) {
if (hash[i] != 0) return false;
}
return true;
} catch (Exception e) {
return false;
}
}
public void setDifficulty(int d) { this.difficulty = d; }
public static class PoWData {
public String challenge;
public int difficulty;
public PoWData(String challenge, int difficulty) {
this.challenge = challenge; this.difficulty = difficulty;
}
}
}
@@ -0,0 +1,113 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.concurrent.ThreadLocalRandom;
public class ScratchImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
private float coverOpacity = 0.75f;
private Color coverColor = new Color(180, 180, 180);
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm) {
super(rm);
}
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform) {
super(rm);
setImageTransform(transform);
}
public ScratchImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform transform, CaptchaInterceptor interceptor) {
super(rm);
setImageTransform(transform);
setInterceptor(interceptor);
}
@Override
protected void doInit() {}
@Override
public void doGenerateCaptchaImage(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
Resource resourceImage = requiredRandomGetResource(param.getType(), param.getBackgroundImageTag());
BufferedImage background = getResourceImage(resourceImage);
int width = background.getWidth();
int height = background.getHeight();
BufferedImage cover = createCoverLayer(width, height);
addScratchPattern(cover, width, height);
captchaExchange.setBackgroundImage(background);
captchaExchange.setTemplateImage(cover);
captchaExchange.setResourceImage(resourceImage);
captchaExchange.setTransferData(new ScratchData(width, height));
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage backgroundImage = captchaExchange.getBackgroundImage();
BufferedImage coverImage = captchaExchange.getTemplateImage();
Resource resourceImage = captchaExchange.getResourceImage();
CustomData customData = captchaExchange.getCustomData();
ImageTransformData transform = getImageTransform().transform(param, backgroundImage, coverImage, resourceImage, null, customData);
ScratchData scratchData = (ScratchData) captchaExchange.getTransferData();
ImageCaptchaInfo info = ImageCaptchaInfo.of(
transform.getBackgroundImageUrl(), transform.getTemplateImageUrl(),
resourceImage.getTag(), null,
backgroundImage.getWidth(), backgroundImage.getHeight(),
coverImage.getWidth(), coverImage.getHeight(),
scratchData.width / 2, "SCRATCH");
info.setData(customData);
// Store scratch threshold in expand for validator
customData.expand = 50; // 50% threshold
return info;
}
private BufferedImage createCoverLayer(int width, int height) {
BufferedImage cover = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = cover.createGraphics();
try {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, coverOpacity));
g2d.setColor(coverColor);
g2d.fillRect(0, 0, width, height);
} finally {
g2d.dispose();
}
return cover;
}
private void addScratchPattern(BufferedImage cover, int width, int height) {
Graphics2D g2d = cover.createGraphics();
try {
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f));
g2d.setColor(Color.LIGHT_GRAY);
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int i = 0; i < 8; i++) {
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);
int x2 = random.nextInt(width);
int y2 = random.nextInt(height);
g2d.setStroke(new BasicStroke(2 + random.nextFloat() * 3));
g2d.drawLine(x1, y1, x2, y2);
}
} finally {
g2d.dispose();
}
}
public static class ScratchData {
public int width;
public int height;
public ScratchData(int w, int h) { this.width = w; this.height = h; }
}
}
@@ -0,0 +1,102 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.common.constant.CommonConstant;
import cloud.tianai.captcha.generator.ImageTransform;
import cloud.tianai.captcha.generator.common.model.dto.*;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public class WordOrderClickImageCaptchaGenerator extends AbstractClickImageCaptchaGenerator {
private static final String[] PHRASES = {
"春暖花开", "风和日丽", "山清水秀", "鸟语花香",
"天高云淡", "秋高气爽", "冰天雪地", "春华秋实",
"龙飞凤舞", "万紫千红", "花好月圆", "国泰民安"
};
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm) { super(rm); }
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t) { super(rm); setImageTransform(t); }
public WordOrderClickImageCaptchaGenerator(ImageCaptchaResourceManager rm, ImageTransform t, CaptchaInterceptor i) { super(rm); setImageTransform(t); setInterceptor(i); }
@Override
protected void doInit() {}
@Override
protected List<ResourceMap> randomGetClickImgTips(GenerateParam param) {
ThreadLocalRandom random = ThreadLocalRandom.current();
String phrase = PHRASES[random.nextInt(PHRASES.length)];
char[] chars = phrase.toCharArray();
List<ResourceMap> result = new ArrayList<>(chars.length);
for (char c : chars) {
ResourceMap map = new ResourceMap("default", 2);
Resource charResource = new Resource("char", String.valueOf(c), "default");
map.put(CommonConstant.IMAGE_CLICK_ICON, charResource);
map.put(CommonConstant.IMAGE_TIP_ICON, charResource);
result.add(map);
}
return result;
}
@Override
public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor, BufferedImage bgImage) {
String text = tip.getData();
int fontSize = 28;
ThreadLocalRandom random = ThreadLocalRandom.current();
float rotation = (random.nextFloat() - 0.5f) * 0.4f;
float scale = 0.9f + random.nextFloat() * 0.3f;
BufferedImage img = new BufferedImage(fontSize * 2, fontSize * 2, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = img.createGraphics();
try {
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.translate(fontSize, fontSize);
g2d.rotate(rotation);
g2d.scale(scale, scale);
g2d.setFont(new Font("SimHei", Font.BOLD, fontSize));
g2d.setColor(randomColor != null ? randomColor : Color.BLACK);
FontMetrics fm = g2d.getFontMetrics();
g2d.drawString(text, -fm.stringWidth(text) / 2, fm.getAscent() / 2);
} finally {
g2d.dispose();
}
ClickImageCheckDefinition.ImgWrapper wrapper = new ClickImageCheckDefinition.ImgWrapper();
wrapper.setImage(img);
wrapper.setImageColor(randomColor != null ? randomColor : Color.BLACK);
return wrapper;
}
@Override
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList) {
return allCheckDefinitionList;
}
@Override
public ImageCaptchaInfo doWrapImageCaptchaInfo(CaptchaExchange captchaExchange) {
GenerateParam param = captchaExchange.getParam();
BufferedImage bgImage = captchaExchange.getBackgroundImage();
Resource resourceImage = captchaExchange.getResourceImage();
CustomData customData = captchaExchange.getCustomData();
List<ClickImageCheckDefinition> checkList = (List<ClickImageCheckDefinition>) captchaExchange.getTransferData();
ImageTransformData transform = getImageTransform().transform(param, bgImage, null, resourceImage, null, customData);
ImageCaptchaInfo info = ImageCaptchaInfo.of(
transform.getBackgroundImageUrl(), null,
resourceImage.getTag(), null,
bgImage.getWidth(), bgImage.getHeight(), 0, 0,
0, "WORD_ORDER_CLICK");
info.setData(customData);
if (checkList != null && customData != null) {
customData.expand = checkList;
}
return info;
}
}
@@ -0,0 +1,49 @@
package cloud.tianai.captcha.interceptor.impl;
import cloud.tianai.captcha.common.AnyMap;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.interceptor.Context;
import cloud.tianai.captcha.risk.RiskEngine;
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
public class RiskControlInterceptor implements CaptchaInterceptor {
private final RiskEngine riskEngine;
public RiskControlInterceptor(RiskEngine riskEngine) {
this.riskEngine = riskEngine;
}
@Override
public ApiResponse<?> beforeValid(Context context, String type, MatchParam matchParam, AnyMap validData) {
String ip = extractIp(matchParam);
String id = context != null ? context.getName() : "unknown";
RiskEngine.RiskResult result = riskEngine.check(ip, id);
if (!result.isAllowed()) {
return ApiResponse.of(4003, result.getReason(), null);
}
return ApiResponse.ofSuccess();
}
@Override
public ApiResponse<?> afterValid(Context context, String type, MatchParam matchParam, AnyMap validData, ApiResponse<?> basicValid) {
String ip = extractIp(matchParam);
if (basicValid != null && basicValid.isSuccess()) {
riskEngine.recordSuccess(ip);
} else {
riskEngine.recordFail(ip);
}
return ApiResponse.ofSuccess();
}
private String extractIp(MatchParam matchParam) {
if (matchParam != null) {
Object ip = matchParam.get("ip");
if (ip instanceof String) {
return (String) ip;
}
}
return "unknown";
}
}
@@ -0,0 +1,245 @@
package cloud.tianai.captcha.ml;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import java.util.ArrayList;
import java.util.List;
public class TrackFeatureExtractor {
public TrackFeatures extract(ImageCaptchaTrack track) {
List<ImageCaptchaTrack.Track> trackList = track.getTrackList();
if (trackList == null || trackList.isEmpty()) {
return TrackFeatures.empty();
}
TrackFeatures features = new TrackFeatures();
features.totalPoints = trackList.size();
List<Float> xList = new ArrayList<>(trackList.size());
List<Float> yList = new ArrayList<>(trackList.size());
List<Float> tList = new ArrayList<>(trackList.size());
for (ImageCaptchaTrack.Track p : trackList) {
xList.add(p.getX());
yList.add(p.getY());
tList.add(p.getT());
}
features.totalDuration = track.getStopTime() != null && track.getStartTime() != null
? track.getStopTime() - track.getStartTime()
: (tList.size() >= 2 ? (long)(tList.get(tList.size() - 1) - tList.get(0)) : 0L);
float startX = xList.get(0);
float startY = yList.get(0);
float endX = xList.get(xList.size() - 1);
float endY = yList.get(yList.size() - 1);
features.startX = startX;
features.startY = startY;
features.endX = endX;
features.endY = endY;
int bgWidth = track.getBgImageWidth() != null ? track.getBgImageWidth() : 600;
features.displacementX = endX - startX;
features.displacementY = endY - startY;
features.displacementXRatio = features.displacementX / bgWidth;
List<Float> speeds = new ArrayList<>(trackList.size() - 1);
List<Float> accelerations = new ArrayList<>(Math.max(0, trackList.size() - 2));
double totalPathLength = 0;
for (int i = 1; i < trackList.size(); i++) {
float dx = xList.get(i) - xList.get(i - 1);
float dy = yList.get(i) - yList.get(i - 1);
float dt = tList.get(i) - tList.get(i - 1);
double dist = Math.sqrt(dx * dx + dy * dy);
totalPathLength += dist;
if (dt > 0) {
speeds.add((float)(dist / dt));
}
}
features.totalPathLength = totalPathLength;
features.pathEfficiency = totalPathLength > 0
? Math.sqrt(features.displacementX * features.displacementX + features.displacementY * features.displacementY) / totalPathLength
: 0;
for (int i = 1; i < speeds.size(); i++) {
accelerations.add(speeds.get(i) - speeds.get(i - 1));
}
features.avgSpeed = average(speeds);
features.maxSpeed = max(speeds);
features.minSpeed = min(speeds);
features.speedVariance = variance(speeds);
features.speedStdDev = stdDev(speeds);
features.speedSkewness = skewness(speeds);
features.avgAcceleration = average(accelerations);
features.maxAcceleration = max(accelerations);
features.minAcceleration = min(accelerations);
features.accelerationVariance = variance(accelerations);
features.directionChanges = countDirectionChanges(xList);
features.yDirectionChanges = countDirectionChanges(yList);
features.pauses = countPauses(tList, 50);
features.startOffset = Math.sqrt(startX * startX + startY * startY);
features.straightness = calculateStraightness(xList, yList);
features.xUniformity = calculateUniformity(xList);
features.yUniformity = calculateUniformity(yList);
features.avgPointInterval = tList.size() > 1
? (tList.get(tList.size() - 1) - tList.get(0)) / (float)(tList.size() - 1)
: 0;
features.speedPhaseCorrelation = calculateSpeedPhaseCorrelation(speeds);
features.maxJumpDistance = calculateMaxJump(xList, yList);
features.overshootRatio = calculateOvershoot(xList, startX, endX);
return features;
}
private float average(List<Float> values) {
if (values.isEmpty()) return 0;
float sum = 0;
for (float v : values) sum += v;
return sum / values.size();
}
private float max(List<Float> values) {
if (values.isEmpty()) return 0;
float m = Float.MIN_VALUE;
for (float v : values) if (v > m) m = v;
return m;
}
private float min(List<Float> values) {
if (values.isEmpty()) return 0;
float m = Float.MAX_VALUE;
for (float v : values) if (v < m) m = v;
return m;
}
private double variance(List<Float> values) {
if (values.size() < 2) return 0;
float avg = average(values);
double sum = 0;
for (float v : values) sum += (v - avg) * (v - avg);
return sum / (values.size() - 1);
}
private double stdDev(List<Float> values) {
return Math.sqrt(variance(values));
}
private double skewness(List<Float> values) {
if (values.size() < 3) return 0;
float avg = average(values);
double sd = stdDev(values);
if (sd == 0) return 0;
double sum = 0;
for (float v : values) {
double norm = (v - avg) / sd;
sum += norm * norm * norm;
}
return sum / values.size();
}
private int countDirectionChanges(List<Float> values) {
int changes = 0;
for (int i = 2; i < values.size(); i++) {
float d1 = values.get(i - 1) - values.get(i - 2);
float d2 = values.get(i) - values.get(i - 1);
if (d1 * d2 < 0) changes++;
}
return changes;
}
private int countPauses(List<Float> tList, long thresholdMs) {
int pauses = 0;
for (int i = 1; i < tList.size(); i++) {
if (tList.get(i) - tList.get(i - 1) > thresholdMs) pauses++;
}
return pauses;
}
private double calculateStraightness(List<Float> xList, List<Float> yList) {
if (xList.size() < 3) return 1.0;
float startX = xList.get(0);
float startY = yList.get(0);
float endX = xList.get(xList.size() - 1);
float endY = yList.get(yList.size() - 1);
double lineLength = Math.sqrt((endX - startX) * (endX - startX) + (endY - startY) * (endY - startY));
if (lineLength == 0) return 1.0;
double totalDeviation = 0;
for (int i = 1; i < xList.size() - 1; i++) {
double d = pointToLineDistance(xList.get(i), yList.get(i), startX, startY, endX, endY);
totalDeviation += d;
}
return totalDeviation / (xList.size() - 2) / lineLength;
}
private double pointToLineDistance(float px, float py, float x1, float y1, float x2, float y2) {
double A = py - y1;
double B = x1 - x2;
double C = x2 * y1 - x1 * y2;
double denom = Math.sqrt(A * A + B * B);
if (denom == 0) return 0;
return Math.abs(A * px + B * py + C) / denom;
}
private double calculateUniformity(List<Float> values) {
if (values.size() < 3) return 0;
List<Float> diffs = new ArrayList<>(values.size() - 1);
for (int i = 1; i < values.size(); i++) {
diffs.add(values.get(i) - values.get(i - 1));
}
return stdDev(diffs);
}
private double calculateSpeedPhaseCorrelation(List<Float> speeds) {
if (speeds.size() < 4) return 0;
int mid = speeds.size() / 2;
float avgFirst = average(speeds.subList(0, mid));
float avgSecond = average(speeds.subList(mid, speeds.size()));
float overallAvg = average(speeds);
if (overallAvg == 0) return 0;
return (avgFirst - avgSecond) / overallAvg;
}
private double calculateMaxJump(List<Float> xList, List<Float> yList) {
double maxJump = 0;
for (int i = 1; i < xList.size(); i++) {
double dx = xList.get(i) - xList.get(i - 1);
double dy = yList.get(i) - yList.get(i - 1);
double jump = Math.sqrt(dx * dx + dy * dy);
if (jump > maxJump) maxJump = jump;
}
return maxJump;
}
private double calculateOvershoot(List<Float> xList, float startX, float endX) {
if (xList.isEmpty()) return 0;
float target = endX;
float maxOvershoot = 0;
boolean passedTarget = false;
for (Float x : xList) {
if (!passedTarget && Math.abs(x - target) < 5) {
passedTarget = true;
}
if (passedTarget) {
float overshoot = Math.abs(x - target);
if (overshoot > maxOvershoot) maxOvershoot = overshoot;
}
}
float totalDisplacement = Math.abs(endX - startX);
return totalDisplacement > 0 ? maxOvershoot / totalDisplacement : 0;
}
}
@@ -0,0 +1,74 @@
package cloud.tianai.captcha.ml;
public class TrackFeatures {
public int totalPoints;
public long totalDuration;
public float startX;
public float startY;
public float endX;
public float endY;
public float displacementX;
public float displacementY;
public float displacementXRatio;
public double totalPathLength;
public double pathEfficiency;
public float avgSpeed;
public float maxSpeed;
public float minSpeed;
public double speedVariance;
public double speedStdDev;
public double speedSkewness;
public float avgAcceleration;
public float maxAcceleration;
public float minAcceleration;
public double accelerationVariance;
public int directionChanges;
public int yDirectionChanges;
public int pauses;
public double startOffset;
public double straightness;
public double xUniformity;
public double yUniformity;
public float avgPointInterval;
public double speedPhaseCorrelation;
public double maxJumpDistance;
public double overshootRatio;
public static TrackFeatures empty() {
return new TrackFeatures();
}
public double[] toArray() {
return new double[]{
totalPoints,
totalDuration,
displacementX,
displacementY,
displacementXRatio,
totalPathLength,
pathEfficiency,
avgSpeed,
maxSpeed,
minSpeed,
speedVariance,
speedStdDev,
speedSkewness,
avgAcceleration,
maxAcceleration,
minAcceleration,
accelerationVariance,
directionChanges,
yDirectionChanges,
pauses,
startOffset,
straightness,
xUniformity,
yUniformity,
avgPointInterval,
speedPhaseCorrelation,
maxJumpDistance,
overshootRatio
};
}
}
@@ -0,0 +1,186 @@
package cloud.tianai.captcha.ml;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import java.util.ArrayList;
import java.util.List;
public class TrackRuleEngine {
private final List<Rule> rules = new ArrayList<>();
private final TrackFeatureExtractor featureExtractor = new TrackFeatureExtractor();
public TrackRuleEngine() {
addDefaultRules();
}
public List<Rule> getRules() {
return rules;
}
public TrackRuleEngine addRule(Rule rule) {
rules.add(rule);
return this;
}
public TrackRuleEngine removeRule(String name) {
rules.removeIf(r -> r.name.equals(name));
return this;
}
public TrackVerdict evaluate(ImageCaptchaTrack track) {
TrackFeatures features = featureExtractor.extract(track);
return evaluate(features);
}
public TrackVerdict evaluate(TrackFeatures features) {
double totalScore = 0;
double totalWeight = 0;
List<RuleResult> results = new ArrayList<>(rules.size());
for (Rule rule : rules) {
RuleResult result = rule.evaluate(features);
results.add(result);
totalScore += result.score * rule.weight;
totalWeight += rule.weight;
}
double finalScore = totalWeight > 0 ? totalScore / totalWeight : 0;
boolean isHuman = finalScore >= 0.5;
TrackVerdict verdict = new TrackVerdict();
verdict.score = finalScore;
verdict.isHuman = isHuman;
verdict.results = results;
verdict.features = features;
return verdict;
}
private void addDefaultRules() {
rules.add(new Rule("duration_check", 1.0, f -> {
if (f.totalDuration < 300) return new RuleResult(0, "Too fast: " + f.totalDuration + "ms");
if (f.totalDuration < 500) return new RuleResult(0.3, "Suspiciously fast: " + f.totalDuration + "ms");
if (f.totalDuration > 30000) return new RuleResult(0.2, "Suspiciously slow: " + f.totalDuration + "ms");
return new RuleResult(1, "Duration OK: " + f.totalDuration + "ms");
}));
rules.add(new Rule("point_count_check", 0.8, f -> {
if (f.totalPoints < 10) return new RuleResult(0, "Too few points: " + f.totalPoints);
if (f.totalPoints > 2000) return new RuleResult(0.1, "Too many points: " + f.totalPoints);
return new RuleResult(1, "Point count OK: " + f.totalPoints);
}));
rules.add(new Rule("start_offset_check", 0.7, f -> {
if (f.startOffset > 20) return new RuleResult(0.1, "Start offset too large: " + f.startOffset);
return new RuleResult(1, "Start offset OK: " + f.startOffset);
}));
rules.add(new Rule("speed_variance_check", 1.2, f -> {
if (f.speedVariance < 0.0001) return new RuleResult(0, "Speed too uniform (bot-like)");
if (f.speedVariance < 0.001) return new RuleResult(0.3, "Speed variance very low");
return new RuleResult(1, "Speed variance OK: " + f.speedVariance);
}));
rules.add(new Rule("straightness_check", 1.0, f -> {
if (f.straightness < 0.01) return new RuleResult(1, "Good curvature: " + f.straightness);
if (f.straightness < 0.05) return new RuleResult(0.7, "Moderate curvature: " + f.straightness);
return new RuleResult(0.2, "Too straight (bot-like): " + f.straightness);
}));
rules.add(new Rule("y_direction_check", 0.6, f -> {
if (f.yDirectionChanges < 2) return new RuleResult(0.2, "Y too stable (bot-like)");
if (f.yDirectionChanges > 50) return new RuleResult(0.3, "Y too erratic");
return new RuleResult(1, "Y direction changes OK: " + f.yDirectionChanges);
}));
rules.add(new Rule("speed_phase_check", 1.0, f -> {
if (f.speedPhaseCorrelation > 0.8) return new RuleResult(0.1, "Speed too uniform across phases");
if (f.speedPhaseCorrelation > 0.5) return new RuleResult(0.4, "Speed somewhat uniform");
return new RuleResult(1, "Speed phase variation OK: " + f.speedPhaseCorrelation);
}));
rules.add(new Rule("max_jump_check", 0.9, f -> {
if (f.maxJumpDistance > 50) return new RuleResult(0, "Jump too large: " + f.maxJumpDistance);
if (f.maxJumpDistance > 30) return new RuleResult(0.4, "Suspicious jump: " + f.maxJumpDistance);
return new RuleResult(1, "Jump distance OK: " + f.maxJumpDistance);
}));
rules.add(new Rule("pause_check", 0.5, f -> {
if (f.pauses > 5) return new RuleResult(0.3, "Too many pauses: " + f.pauses);
if (f.pauses >= 1) return new RuleResult(1, "Natural pauses: " + f.pauses);
return new RuleResult(0.6, "No pauses (could be bot)");
}));
rules.add(new Rule("overshoot_check", 0.8, f -> {
if (f.overshootRatio > 0.3) return new RuleResult(0.2, "Large overshoot: " + f.overshootRatio);
if (f.overshootRatio > 0.05) return new RuleResult(1, "Natural overshoot: " + f.overshootRatio);
return new RuleResult(0.7, "No overshoot (could be bot)");
}));
rules.add(new Rule("acceleration_check", 0.7, f -> {
if (f.accelerationVariance < 0.0001) return new RuleResult(0.1, "Acceleration too uniform");
return new RuleResult(1, "Acceleration variance OK: " + f.accelerationVariance);
}));
rules.add(new Rule("x_uniformity_check", 0.8, f -> {
if (f.xUniformity < 0.5) return new RuleResult(0.1, "X movement too uniform (bot-like)");
return new RuleResult(1, "X uniformity OK: " + f.xUniformity);
}));
rules.add(new Rule("speed_skewness_check", 0.6, f -> {
if (Math.abs(f.speedSkewness) < 0.1) return new RuleResult(0.3, "Speed distribution too symmetric");
return new RuleResult(1, "Speed skewness OK: " + f.speedSkewness);
}));
rules.add(new Rule("path_efficiency_check", 0.5, f -> {
if (f.pathEfficiency > 0.98) return new RuleResult(0.2, "Path too efficient (straight line)");
if (f.pathEfficiency < 0.3) return new RuleResult(0.3, "Path too inefficient (erratic)");
return new RuleResult(1, "Path efficiency OK: " + f.pathEfficiency);
}));
}
public static class Rule {
public final String name;
public final double weight;
public final java.util.function.Function<TrackFeatures, RuleResult> evaluator;
public Rule(String name, double weight, java.util.function.Function<TrackFeatures, RuleResult> evaluator) {
this.name = name;
this.weight = weight;
this.evaluator = evaluator;
}
public RuleResult evaluate(TrackFeatures features) {
return evaluator.apply(features);
}
}
public static class RuleResult {
public final double score;
public final String reason;
public RuleResult(double score, String reason) {
this.score = score;
this.reason = reason;
}
}
public static class TrackVerdict {
public double score;
public boolean isHuman;
public List<RuleResult> results;
public TrackFeatures features;
public boolean isBot() {
return !isHuman;
}
public String getSummary() {
StringBuilder sb = new StringBuilder();
sb.append("Verdict: ").append(isHuman ? "HUMAN" : "BOT").append(" (score=").append(String.format("%.3f", score)).append(")\n");
for (int i = 0; i < results.size(); i++) {
sb.append(" Rule ").append(i + 1).append(": ").append(results.get(i).reason).append("\n");
}
return sb.toString();
}
}
}
@@ -0,0 +1,81 @@
package cloud.tianai.captcha.obfuscator;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
public class BackgroundShuffler implements ImageObfuscator {
private final Random random = new Random();
@Override
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
if (!config.isBackgroundShuffleEnabled()) {
return image;
}
int rows = config.getBackgroundShuffleRows();
int cols = config.getBackgroundShuffleCols();
int width = image.getWidth();
int height = image.getHeight();
int tileWidth = width / cols;
int tileHeight = height / rows;
List<Integer> indices = new ArrayList<>(rows * cols);
for (int i = 0; i < rows * cols; i++) {
indices.add(i);
}
Collections.shuffle(indices, random);
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = result.createGraphics();
for (int idx = 0; idx < indices.size(); idx++) {
int srcRow = idx / cols;
int srcCol = idx % cols;
int dstRow = indices.get(idx) / cols;
int dstCol = indices.get(idx) % cols;
int srcX = srcCol * tileWidth;
int srcY = srcRow * tileHeight;
int dstX = dstCol * tileWidth;
int dstY = dstRow * tileHeight;
BufferedImage tile = image.getSubimage(
Math.min(srcX, width - tileWidth),
Math.min(srcY, height - tileHeight),
tileWidth,
tileHeight
);
g2d.drawImage(tile, dstX, dstY, null);
}
g2d.dispose();
return result;
}
@Override
public String getName() {
return "background_shuffle";
}
public static class ShuffleMetadata {
public List<Integer> originalIndices;
public int rows;
public int cols;
public List<Integer> getRestoreOrder() {
if (originalIndices == null) return Collections.emptyList();
Integer[] restore = new Integer[originalIndices.size()];
for (int i = 0; i < originalIndices.size(); i++) {
restore[originalIndices.get(i)] = i;
}
List<Integer> result = new ArrayList<>(originalIndices.size());
Collections.addAll(result, restore);
return result;
}
}
}
@@ -0,0 +1,40 @@
package cloud.tianai.captcha.obfuscator;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
public class CompositeObfuscator implements ImageObfuscator {
private final List<ImageObfuscator> obfuscators = new ArrayList<>();
public CompositeObfuscator() {
obfuscators.add(new BackgroundShuffler());
obfuscators.add(new SinDistorter());
obfuscators.add(new NoiseInjector());
}
public CompositeObfuscator addObfuscator(ImageObfuscator obfuscator) {
obfuscators.add(obfuscator);
return this;
}
public CompositeObfuscator removeObfuscator(String name) {
obfuscators.removeIf(o -> o.getName().equals(name));
return this;
}
@Override
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
BufferedImage result = image;
for (ImageObfuscator obfuscator : obfuscators) {
result = obfuscator.obfuscate(result, config);
}
return result;
}
@Override
public String getName() {
return "composite";
}
}
@@ -0,0 +1,10 @@
package cloud.tianai.captcha.obfuscator;
import java.awt.image.BufferedImage;
public interface ImageObfuscator {
BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config);
String getName();
}
@@ -0,0 +1,104 @@
package cloud.tianai.captcha.obfuscator;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;
public class NoiseInjector implements ImageObfuscator {
private final Random random = new Random();
@Override
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
BufferedImage result = copyImage(image);
Graphics2D g2d = result.createGraphics();
int width = result.getWidth();
int height = result.getHeight();
if (config.isNoiseEnabled()) {
injectPointNoise(g2d, width, height, config);
}
if (config.isLineNoiseEnabled()) {
injectLineNoise(g2d, width, height, config);
}
if (config.isColorShiftEnabled()) {
g2d.dispose();
result = applyColorShift(result, config);
g2d.dispose();
return result;
}
g2d.dispose();
return result;
}
private void injectPointNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) {
int count = config.getNoisePointCount();
float alpha = config.getNoiseAlpha();
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha));
for (int i = 0; i < count; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int rgb = random.nextInt(0xFFFFFF);
g2d.setColor(new Color(rgb));
int size = 1 + random.nextInt(3);
g2d.fillOval(x, y, size, size);
}
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
}
private void injectLineNoise(Graphics2D g2d, int width, int height, ObfuscatorConfig config) {
int count = config.getLineNoiseCount();
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f));
for (int i = 0; i < count; i++) {
int x1 = random.nextInt(width);
int y1 = random.nextInt(height);
int x2 = random.nextInt(width);
int y2 = random.nextInt(height);
int rgb = random.nextInt(0xFFFFFF);
g2d.setColor(new Color(rgb));
g2d.setStroke(new BasicStroke(1 + random.nextFloat()));
g2d.drawLine(x1, y1, x2, y2);
}
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
}
private BufferedImage applyColorShift(BufferedImage image, ObfuscatorConfig config) {
int width = image.getWidth();
int height = image.getHeight();
int range = config.getColorShiftRange();
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int argb = image.getRGB(x, y);
int a = (argb >> 24) & 0xFF;
int r = Math.max(0, Math.min(255, ((argb >> 16) & 0xFF) + random.nextInt(range * 2 + 1) - range));
int g = Math.max(0, Math.min(255, ((argb >> 8) & 0xFF) + random.nextInt(range * 2 + 1) - range));
int b = Math.max(0, Math.min(255, (argb & 0xFF) + random.nextInt(range * 2 + 1) - range));
result.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b);
}
}
return result;
}
private BufferedImage copyImage(BufferedImage source) {
BufferedImage copy = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics2D g = copy.createGraphics();
g.drawImage(source, 0, 0, null);
g.dispose();
return copy;
}
@Override
public String getName() {
return "noise_inject";
}
}
@@ -0,0 +1,83 @@
package cloud.tianai.captcha.obfuscator;
public class ObfuscatorConfig {
private boolean backgroundShuffleEnabled = false;
private int backgroundShuffleRows = 3;
private int backgroundShuffleCols = 4;
private boolean sinDistortEnabled = false;
private double sinAmplitude = 3.0;
private double sinFrequency = 0.05;
private boolean noiseEnabled = false;
private int noisePointCount = 50;
private float noiseAlpha = 0.3f;
private boolean lineNoiseEnabled = false;
private int lineNoiseCount = 3;
private boolean colorShiftEnabled = false;
private int colorShiftRange = 10;
private boolean blurEnabled = false;
private float blurRadius = 1.0f;
public static ObfuscatorConfig defaultConfig() {
ObfuscatorConfig config = new ObfuscatorConfig();
config.setBackgroundShuffleEnabled(true);
config.setSinDistortEnabled(true);
config.setNoiseEnabled(true);
config.setLineNoiseEnabled(true);
config.setColorShiftEnabled(true);
return config;
}
public static ObfuscatorConfig none() {
return new ObfuscatorConfig();
}
public static ObfuscatorConfig high() {
ObfuscatorConfig config = defaultConfig();
config.setBackgroundShuffleRows(4);
config.setBackgroundShuffleCols(6);
config.setSinAmplitude(5.0);
config.setSinFrequency(0.08);
config.setNoisePointCount(100);
config.setLineNoiseCount(5);
config.setColorShiftRange(20);
config.setBlurEnabled(true);
return config;
}
public boolean isBackgroundShuffleEnabled() { return backgroundShuffleEnabled; }
public void setBackgroundShuffleEnabled(boolean v) { this.backgroundShuffleEnabled = v; }
public int getBackgroundShuffleRows() { return backgroundShuffleRows; }
public void setBackgroundShuffleRows(int v) { this.backgroundShuffleRows = v; }
public int getBackgroundShuffleCols() { return backgroundShuffleCols; }
public void setBackgroundShuffleCols(int v) { this.backgroundShuffleCols = v; }
public boolean isSinDistortEnabled() { return sinDistortEnabled; }
public void setSinDistortEnabled(boolean v) { this.sinDistortEnabled = v; }
public double getSinAmplitude() { return sinAmplitude; }
public void setSinAmplitude(double v) { this.sinAmplitude = v; }
public double getSinFrequency() { return sinFrequency; }
public void setSinFrequency(double v) { this.sinFrequency = v; }
public boolean isNoiseEnabled() { return noiseEnabled; }
public void setNoiseEnabled(boolean v) { this.noiseEnabled = v; }
public int getNoisePointCount() { return noisePointCount; }
public void setNoisePointCount(int v) { this.noisePointCount = v; }
public float getNoiseAlpha() { return noiseAlpha; }
public void setNoiseAlpha(float v) { this.noiseAlpha = v; }
public boolean isLineNoiseEnabled() { return lineNoiseEnabled; }
public void setLineNoiseEnabled(boolean v) { this.lineNoiseEnabled = v; }
public int getLineNoiseCount() { return lineNoiseCount; }
public void setLineNoiseCount(int v) { this.lineNoiseCount = v; }
public boolean isColorShiftEnabled() { return colorShiftEnabled; }
public void setColorShiftEnabled(boolean v) { this.colorShiftEnabled = v; }
public int getColorShiftRange() { return colorShiftRange; }
public void setColorShiftRange(int v) { this.colorShiftRange = v; }
public boolean isBlurEnabled() { return blurEnabled; }
public void setBlurEnabled(boolean v) { this.blurEnabled = v; }
public float getBlurRadius() { return blurRadius; }
public void setBlurRadius(float v) { this.blurRadius = v; }
}
@@ -0,0 +1,37 @@
package cloud.tianai.captcha.obfuscator;
import java.awt.image.BufferedImage;
public class SinDistorter implements ImageObfuscator {
@Override
public BufferedImage obfuscate(BufferedImage image, ObfuscatorConfig config) {
if (!config.isSinDistortEnabled()) {
return image;
}
int width = image.getWidth();
int height = image.getHeight();
double amplitude = config.getSinAmplitude();
double frequency = config.getSinFrequency();
BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
double xOffset = amplitude * Math.sin(2 * Math.PI * frequency * y);
for (int x = 0; x < width; x++) {
int srcX = (int) Math.round(x - xOffset);
if (srcX >= 0 && srcX < width) {
result.setRGB(x, y, image.getRGB(srcX, y));
}
}
}
return result;
}
@Override
public String getName() {
return "sin_distort";
}
}
@@ -0,0 +1,62 @@
package cloud.tianai.captcha.risk;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class IpBlacklist {
private final ConcurrentHashMap<String, Long> blacklist = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Integer> failCounts = new ConcurrentHashMap<>();
private int maxFailCount = 10;
private long banDurationMs = 3600000;
public boolean isBanned(String ip) {
Long banUntil = blacklist.get(ip);
if (banUntil == null) {
return false;
}
if (System.currentTimeMillis() > banUntil) {
blacklist.remove(ip);
failCounts.remove(ip);
return false;
}
return true;
}
public void recordFail(String ip) {
int count = failCounts.merge(ip, 1, Integer::sum);
if (count >= maxFailCount) {
ban(ip);
}
}
public void ban(String ip) {
blacklist.put(ip, System.currentTimeMillis() + banDurationMs);
}
public void ban(String ip, long durationMs) {
blacklist.put(ip, System.currentTimeMillis() + durationMs);
}
public void unban(String ip) {
blacklist.remove(ip);
failCounts.remove(ip);
}
public void recordSuccess(String ip) {
failCounts.remove(ip);
}
public Set<String> getBannedIps() {
return blacklist.keySet();
}
public int getFailCount(String ip) {
return failCounts.getOrDefault(ip, 0);
}
public void configure(int maxFailCount, long banDurationMs) {
this.maxFailCount = maxFailCount;
this.banDurationMs = banDurationMs;
}
}
@@ -0,0 +1,79 @@
package cloud.tianai.captcha.risk;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
public class RateLimiter {
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
private long windowSizeMs;
private int maxRequests;
private final CleanupThread cleanupThread;
public RateLimiter(long windowSizeMs, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
this.cleanupThread = new CleanupThread();
this.cleanupThread.setDaemon(true);
this.cleanupThread.start();
}
public boolean allow(String key) {
SlidingWindow window = windows.computeIfAbsent(key, k -> new SlidingWindow(windowSizeMs));
return window.increment() <= maxRequests;
}
public int getCurrentCount(String key) {
SlidingWindow window = windows.get(key);
return window != null ? (int) window.count.get() : 0;
}
public void reset(String key) {
windows.remove(key);
}
public void configure(long windowSizeMs, int maxRequests) {
this.windowSizeMs = windowSizeMs;
this.maxRequests = maxRequests;
}
private class SlidingWindow {
private final AtomicLong count = new AtomicLong(0);
private volatile long startTime;
SlidingWindow(long windowSizeMs) {
this.startTime = System.currentTimeMillis();
}
long increment() {
long now = System.currentTimeMillis();
if (now - startTime > windowSizeMs) {
synchronized (this) {
if (now - startTime > windowSizeMs) {
startTime = now;
count.set(0);
}
}
}
return count.incrementAndGet();
}
}
private class CleanupThread extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(60000);
long now = System.currentTimeMillis();
windows.entrySet().removeIf(entry ->
now - entry.getValue().startTime > windowSizeMs * 2
);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
}
@@ -0,0 +1,71 @@
package cloud.tianai.captcha.risk;
public class RiskEngine {
private final RateLimiter rateLimiter;
private final IpBlacklist ipBlacklist;
private boolean rateLimitEnabled = true;
private boolean ipBlacklistEnabled = true;
public RiskEngine() {
this.rateLimiter = new RateLimiter(60000, 60);
this.ipBlacklist = new IpBlacklist();
}
public RiskEngine(RateLimiter rateLimiter, IpBlacklist ipBlacklist) {
this.rateLimiter = rateLimiter;
this.ipBlacklist = ipBlacklist;
}
public RiskResult check(String ip, String captchaId) {
RiskResult result = new RiskResult();
if (ipBlacklistEnabled && ipBlacklist.isBanned(ip)) {
result.allowed = false;
result.reason = "ip_banned";
return result;
}
if (rateLimitEnabled && !rateLimiter.allow("ip:" + ip)) {
result.allowed = false;
result.reason = "rate_limit_ip";
return result;
}
if (rateLimitEnabled && !rateLimiter.allow("id:" + captchaId)) {
result.allowed = false;
result.reason = "rate_limit_id";
return result;
}
result.allowed = true;
result.reason = "ok";
return result;
}
public void recordSuccess(String ip) {
if (ipBlacklistEnabled) {
ipBlacklist.recordSuccess(ip);
}
}
public void recordFail(String ip) {
if (ipBlacklistEnabled) {
ipBlacklist.recordFail(ip);
}
}
public RateLimiter getRateLimiter() { return rateLimiter; }
public IpBlacklist getIpBlacklist() { return ipBlacklist; }
public void setRateLimitEnabled(boolean v) { this.rateLimitEnabled = v; }
public void setIpBlacklistEnabled(boolean v) { this.ipBlacklistEnabled = v; }
public static class RiskResult {
public boolean allowed;
public String reason;
public boolean isAllowed() { return allowed; }
public String getReason() { return reason; }
}
}
@@ -0,0 +1,53 @@
package cloud.tianai.captcha.site;
import java.util.Set;
public class SiteConfig {
private String siteId;
private String siteKey;
private String secretKey;
private String name;
private Set<String> domains;
private boolean enabled = true;
private Set<String> allowedTypes;
private String level = "normal";
private int maxQps = 100;
private long captchaExpireMs = 120000;
private float tolerant = 0.02f;
private boolean trackValidationEnabled = true;
private double trackHumanThreshold = 0.5;
private boolean obfuscationEnabled = true;
private boolean encryptionEnabled = true;
public String getSiteId() { return siteId; }
public void setSiteId(String v) { this.siteId = v; }
public String getSiteKey() { return siteKey; }
public void setSiteKey(String v) { this.siteKey = v; }
public String getSecretKey() { return secretKey; }
public void setSecretKey(String v) { this.secretKey = v; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public Set<String> getDomains() { return domains; }
public void setDomains(Set<String> v) { this.domains = v; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean v) { this.enabled = v; }
public Set<String> getAllowedTypes() { return allowedTypes; }
public void setAllowedTypes(Set<String> v) { this.allowedTypes = v; }
public String getLevel() { return level; }
public void setLevel(String v) { this.level = v; }
public int getMaxQps() { return maxQps; }
public void setMaxQps(int v) { this.maxQps = v; }
public long getCaptchaExpireMs() { return captchaExpireMs; }
public void setCaptchaExpireMs(long v) { this.captchaExpireMs = v; }
public float getTolerant() { return tolerant; }
public void setTolerant(float v) { this.tolerant = v; }
public boolean isTrackValidationEnabled() { return trackValidationEnabled; }
public void setTrackValidationEnabled(boolean v) { this.trackValidationEnabled = v; }
public double getTrackHumanThreshold() { return trackHumanThreshold; }
public void setTrackHumanThreshold(double v) { this.trackHumanThreshold = v; }
public boolean isObfuscationEnabled() { return obfuscationEnabled; }
public void setObfuscationEnabled(boolean v) { this.obfuscationEnabled = v; }
public boolean isEncryptionEnabled() { return encryptionEnabled; }
public void setEncryptionEnabled(boolean v) { this.encryptionEnabled = v; }
}
@@ -0,0 +1,83 @@
package cloud.tianai.captcha.site;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class SiteManager {
private final ConcurrentHashMap<String, SiteConfig> sitesBySiteKey = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, SiteConfig> sitesBySiteId = new ConcurrentHashMap<>();
private final SecureRandom random = new SecureRandom();
public SiteConfig registerSite(String name, Set<String> domains, Set<String> allowedTypes) {
SiteConfig config = new SiteConfig();
config.setSiteId(generateId());
config.setSiteKey(generateKey());
config.setSecretKey(generateKey());
config.setName(name);
config.setDomains(domains);
config.setAllowedTypes(allowedTypes);
sitesBySiteKey.put(config.getSiteKey(), config);
sitesBySiteId.put(config.getSiteId(), config);
return config;
}
public SiteConfig getSiteBySiteKey(String siteKey) {
return sitesBySiteKey.get(siteKey);
}
public SiteConfig getSiteBySiteId(String siteId) {
return sitesBySiteId.get(siteId);
}
public boolean validateSiteKey(String siteKey, String domain) {
SiteConfig config = sitesBySiteKey.get(siteKey);
if (config == null || !config.isEnabled()) {
return false;
}
if (domain != null && config.getDomains() != null && !config.getDomains().isEmpty()) {
return config.getDomains().contains(domain) || config.getDomains().contains("*");
}
return true;
}
public boolean validateSecretKey(String siteKey, String secretKey) {
SiteConfig config = sitesBySiteKey.get(siteKey);
return config != null && config.getSecretKey().equals(secretKey);
}
public boolean isTypeAllowed(String siteKey, String type) {
SiteConfig config = sitesBySiteKey.get(siteKey);
if (config == null) return false;
if (config.getAllowedTypes() == null || config.getAllowedTypes().isEmpty()) return true;
return config.getAllowedTypes().contains(type);
}
public void removeSite(String siteKey) {
SiteConfig config = sitesBySiteKey.remove(siteKey);
if (config != null) {
sitesBySiteId.remove(config.getSiteId());
}
}
public Collection<SiteConfig> getAllSites() {
return Collections.unmodifiableCollection(sitesBySiteKey.values());
}
private String generateId() {
byte[] bytes = new byte[16];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String generateKey() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,44 @@
package cloud.tianai.captcha.site;
import cloud.tianai.captcha.cache.CacheStore;
import cloud.tianai.captcha.common.AnyMap;
import java.util.Base64;
import java.util.concurrent.TimeUnit;
public class TokenService {
private final CacheStore cacheStore;
private final String tokenPrefix = "captcha:token:";
private long tokenExpireMs = 120000;
public TokenService(CacheStore cacheStore) {
this.cacheStore = cacheStore;
}
public String generateToken(String siteKey, String captchaId) {
byte[] tokenBytes = new byte[32];
new java.security.SecureRandom().nextBytes(tokenBytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
AnyMap data = new AnyMap();
data.put("siteKey", siteKey);
data.put("captchaId", captchaId);
data.put("createdAt", System.currentTimeMillis());
cacheStore.setCache(tokenPrefix + token, data, tokenExpireMs, TimeUnit.MILLISECONDS);
return token;
}
public AnyMap consumeToken(String token) {
return cacheStore.getAndRemoveCache(tokenPrefix + token);
}
public boolean validateToken(String token) {
return cacheStore.getCache(tokenPrefix + token) != null;
}
public void setTokenExpireMs(long tokenExpireMs) {
this.tokenExpireMs = tokenExpireMs;
}
}
@@ -0,0 +1,67 @@
package cloud.tianai.captcha.validator.impl;
import cloud.tianai.captcha.common.AnyMap;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.common.response.ApiResponseStatusConstant;
import cloud.tianai.captcha.ml.TrackRuleEngine;
import cloud.tianai.captcha.ml.TrackRuleEngine.TrackVerdict;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
public class EnhancedTrackValidator extends SimpleImageCaptchaValidator {
public static final int TRACK_CHECK_FAIL_CODE = 4002;
public static final int TRACK_EMPTY_CODE = 4003;
private TrackRuleEngine ruleEngine;
private double humanThreshold = 0.5;
private boolean enabled = true;
public EnhancedTrackValidator() {
this.ruleEngine = new TrackRuleEngine();
}
public EnhancedTrackValidator(float defaultTolerant) {
super(defaultTolerant);
this.ruleEngine = new TrackRuleEngine();
}
@Override
public ApiResponse<?> afterValid(Boolean basicValid, ImageCaptchaTrack imageCaptchaTrack,
AnyMap captchaValidData, Float tolerant, String type) {
if (!basicValid) {
return ApiResponse.ofMessage(ApiResponseStatusConstant.BASIC_CHECK_FAIL);
}
if (!enabled) {
return ApiResponse.ofSuccess();
}
if (imageCaptchaTrack.getTrackList() == null || imageCaptchaTrack.getTrackList().isEmpty()) {
return ApiResponse.of(TRACK_EMPTY_CODE, "track_empty", null);
}
TrackVerdict verdict = ruleEngine.evaluate(imageCaptchaTrack);
if (verdict.isBot()) {
return ApiResponse.of(TRACK_CHECK_FAIL_CODE, "track_check_fail", null);
}
return ApiResponse.ofSuccess();
}
public void setRuleEngine(TrackRuleEngine ruleEngine) {
this.ruleEngine = ruleEngine;
}
public void setHumanThreshold(double humanThreshold) {
this.humanThreshold = humanThreshold;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public TrackRuleEngine getRuleEngine() {
return ruleEngine;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB