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 图标预渲染工具
@@ -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/<name>.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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |