package com.trafficaudit.security.captcha;
|
|
import org.springframework.stereotype.Service;
|
|
import java.util.ArrayList;
|
import java.util.Arrays;
|
import java.util.Collections;
|
import java.util.HashMap;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Locale;
|
import java.util.Map;
|
import java.util.Random;
|
import java.util.concurrent.ConcurrentHashMap;
|
|
/**
|
* 行为验证码:按提示顺序点击立体图形。
|
* 服务端直接生成每个图形的等轴测几何面(多边形/椭圆/路径),前端只负责渲染,
|
* 响应中不包含任何图形类型名称,答案只保存在服务端。
|
*/
|
@Service
|
public class CaptchaService {
|
|
public static final int CANVAS_W = 340;
|
public static final int CANVAS_H = 148;
|
|
private static final int SHAPE_COUNT = 6;
|
private static final int ANSWER_COUNT = 4;
|
private static final long TTL_MILLIS = 5 * 60 * 1000L;
|
private static final int MAX_FAIL = 3;
|
private static final char[] ID_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789".toCharArray();
|
|
private static final Map<String, String> KIND_CN = new LinkedHashMap<>();
|
|
static {
|
KIND_CN.put("CUBE", "正方体");
|
KIND_CN.put("BOX", "长方体");
|
KIND_CN.put("CYLINDER", "圆柱");
|
KIND_CN.put("CONE", "圆锥");
|
KIND_CN.put("SPHERE", "球");
|
KIND_CN.put("TRI_PRISM", "三棱柱");
|
KIND_CN.put("PYRAMID", "金字塔");
|
KIND_CN.put("HEX_PRISM", "六棱柱");
|
}
|
|
private static final String[] KINDS = KIND_CN.keySet().toArray(new String[0]);
|
private static final String[] PATTERNS = {"dots", "grid", "lines", "rings", "cross", "tri"};
|
|
private final Map<String, Challenge> store = new ConcurrentHashMap<>();
|
private final Random random = new Random();
|
|
public enum VerifyResult { OK, WRONG, NOT_FOUND, RETRY_LIMIT }
|
|
public enum ConsumeResult { OK, WRONG, NOT_FOUND, NOT_VERIFIED }
|
|
private static class Challenge {
|
String id;
|
List<String> answerKinds;
|
Map<String, String> idToKind;
|
boolean verified;
|
long expireAt;
|
int failCount;
|
}
|
|
/** 生成一组挑战,返回可直接下发前端的 JSON 结构。 */
|
public Map<String, Object> generate() {
|
boolean darkBg = random.nextBoolean();
|
int bgHue = random.nextInt(360);
|
int bgSat = 30 + random.nextInt(40);
|
int bgLight = darkBg ? 14 + random.nextInt(16) : 74 + random.nextInt(16);
|
String pattern = PATTERNS[random.nextInt(PATTERNS.length)];
|
int patHue = (bgHue + 120 + random.nextInt(200)) % 360;
|
|
int shapeSat = 55 + random.nextInt(30);
|
int shapeLight = darkBg ? 60 + random.nextInt(18) : 28 + random.nextInt(16);
|
int shapeHue = (bgHue + 30 + random.nextInt(300)) % 360;
|
|
// 答案序列:随机取 ANSWER_COUNT 个不重复类型;其余位置用剩余类型填充
|
List<String> pool = new ArrayList<>(Arrays.asList(KINDS));
|
Collections.shuffle(pool, random);
|
List<String> answerKinds = new ArrayList<>(pool.subList(0, ANSWER_COUNT));
|
List<String> allKinds = new ArrayList<>();
|
for (int i = 0; i < SHAPE_COUNT; i++) {
|
if (i < ANSWER_COUNT) {
|
allKinds.add(answerKinds.get(i));
|
} else {
|
allKinds.add(pool.get(ANSWER_COUNT + random.nextInt(pool.size() - ANSWER_COUNT)));
|
}
|
}
|
Collections.shuffle(allKinds, random);
|
|
int cols = 3, rows = 2;
|
List<Map<String, Object>> shapes = new ArrayList<>();
|
Map<String, String> idToKind = new HashMap<>();
|
for (int i = 0; i < SHAPE_COUNT; i++) {
|
int col = i % cols, row = i / cols;
|
double x = CANVAS_W * (col + 0.5) / cols + (random.nextDouble() * 24 - 12);
|
double y = CANVAS_H * (row + 0.5) / rows + (random.nextDouble() * 20 - 10);
|
double size = 0.6 + random.nextDouble() * 0.35;
|
double rotate = (random.nextInt(5) - 2) * 6 + (random.nextInt(5) - 2);
|
String kind = allKinds.get(i);
|
String id = randomId();
|
idToKind.put(id, kind);
|
shapes.add(buildShape(kind, id, x, y, size, rotate,
|
(shapeHue + random.nextInt(40) - 20 + 360) % 360, shapeSat, shapeLight));
|
}
|
|
Challenge ch = new Challenge();
|
ch.id = randomId();
|
ch.answerKinds = answerKinds;
|
ch.idToKind = idToKind;
|
ch.expireAt = System.currentTimeMillis() + TTL_MILLIS;
|
store.put(ch.id, ch);
|
|
Map<String, Object> data = new LinkedHashMap<>();
|
data.put("captchaId", ch.id);
|
List<String> prompt = new ArrayList<>();
|
for (String k : answerKinds) {
|
prompt.add(KIND_CN.get(k));
|
}
|
data.put("prompt", prompt);
|
|
Map<String, Object> bg = new LinkedHashMap<>();
|
bg.put("hue", bgHue);
|
bg.put("sat", bgSat);
|
bg.put("light", bgLight);
|
bg.put("pattern", pattern);
|
bg.put("patternHue", patHue);
|
bg.put("patternSat", 50);
|
bg.put("patternLight", 60);
|
bg.put("patternOpacity", 0.22);
|
bg.put("patternSize", 9 + random.nextInt(8));
|
data.put("background", bg);
|
data.put("shapes", shapes);
|
return data;
|
}
|
|
/** 校验点击顺序;通过后标记已认证,登录时消费。 */
|
public VerifyResult verify(String captchaId, List<String> clickIds) {
|
Challenge ch = getLive(captchaId);
|
if (ch == null) {
|
return VerifyResult.NOT_FOUND;
|
}
|
if (match(ch, clickIds)) {
|
ch.verified = true;
|
return VerifyResult.OK;
|
}
|
ch.failCount++;
|
if (ch.failCount >= MAX_FAIL) {
|
store.remove(ch.id);
|
return VerifyResult.RETRY_LIMIT;
|
}
|
return VerifyResult.WRONG;
|
}
|
|
/** 登录时消费验证码:一次性,校验答案并删除挑战。 */
|
public ConsumeResult consume(String captchaId, List<String> clickIds) {
|
Challenge ch = getLive(captchaId);
|
if (ch == null) {
|
return ConsumeResult.NOT_FOUND;
|
}
|
store.remove(ch.id);
|
if (!ch.verified) {
|
return ConsumeResult.NOT_VERIFIED;
|
}
|
if (!match(ch, clickIds)) {
|
return ConsumeResult.WRONG;
|
}
|
return ConsumeResult.OK;
|
}
|
|
private Challenge getLive(String captchaId) {
|
if (captchaId == null || captchaId.isEmpty()) {
|
return null;
|
}
|
Challenge ch = store.get(captchaId);
|
if (ch == null) {
|
return null;
|
}
|
if (ch.expireAt < System.currentTimeMillis()) {
|
store.remove(ch.id);
|
return null;
|
}
|
return ch;
|
}
|
|
private boolean match(Challenge ch, List<String> clickIds) {
|
if (clickIds == null || clickIds.isEmpty() || clickIds.size() != ch.answerKinds.size()) {
|
return false;
|
}
|
List<String> got = new ArrayList<>();
|
for (String id : clickIds) {
|
String kind = ch.idToKind.get(id);
|
if (kind == null) {
|
return false;
|
}
|
got.add(kind);
|
}
|
return ch.answerKinds.equals(got);
|
}
|
|
private String randomId() {
|
StringBuilder sb = new StringBuilder(6);
|
for (int i = 0; i < 6; i++) {
|
sb.append(ID_CHARS[random.nextInt(ID_CHARS.length)]);
|
}
|
return sb.toString();
|
}
|
|
// ==================== 等轴测几何生成 ====================
|
|
private Map<String, Object> buildShape(String kind, String id, double x, double y, double size,
|
double rotate, int hue, int sat, int light) {
|
ShapeArt art;
|
switch (kind) {
|
case "CUBE": art = cube(hue, sat, light); break;
|
case "BOX": art = box(hue, sat, light); break;
|
case "CYLINDER": art = cylinder(hue, sat, light); break;
|
case "CONE": art = cone(hue, sat, light); break;
|
case "SPHERE": art = sphere(hue, sat, light); break;
|
case "TRI_PRISM": art = triPrism(hue, sat, light); break;
|
case "PYRAMID": art = pyramid(hue, sat, light); break;
|
case "HEX_PRISM": art = hexPrism(hue, sat, light); break;
|
default: art = cube(hue, sat, light);
|
}
|
Map<String, Object> m = new LinkedHashMap<>();
|
m.put("id", id);
|
m.put("x", x);
|
m.put("y", y);
|
m.put("size", size);
|
m.put("rotate", rotate);
|
m.put("hitW", art.bbox[0]);
|
m.put("hitH", art.bbox[1]);
|
m.put("faces", art.faces);
|
return m;
|
}
|
|
private static class ShapeArt {
|
List<Map<String, Object>> faces;
|
double[] bbox; // {w, h} 未旋转未缩放时的包围盒
|
|
ShapeArt(List<Map<String, Object>> faces, double w, double h) {
|
this.faces = faces;
|
this.bbox = new double[]{w, h};
|
}
|
}
|
|
private static Map<String, Object> poly(String pts, String fill, String stroke) {
|
Map<String, Object> f = new LinkedHashMap<>();
|
f.put("type", "poly");
|
f.put("pts", pts);
|
f.put("fill", fill);
|
f.put("stroke", stroke);
|
f.put("sw", 1.2);
|
return f;
|
}
|
|
private static Map<String, Object> ellipse(double cx, double cy, double rx, double ry,
|
String fill, String stroke, double opacity) {
|
Map<String, Object> f = new LinkedHashMap<>();
|
f.put("type", "ellipse");
|
f.put("cx", cx);
|
f.put("cy", cy);
|
f.put("rx", rx);
|
f.put("ry", ry);
|
f.put("fill", fill);
|
f.put("stroke", stroke);
|
f.put("sw", opacity > 0 ? 0 : 1.2);
|
f.put("opacity", opacity);
|
return f;
|
}
|
|
private static Map<String, Object> path(String d, String fill, String stroke) {
|
Map<String, Object> f = new LinkedHashMap<>();
|
f.put("type", "path");
|
f.put("d", d);
|
f.put("fill", fill);
|
f.put("stroke", stroke);
|
f.put("sw", 1.2);
|
return f;
|
}
|
|
private static String hsl(int h, int s, int l) {
|
return "hsl(" + h + "," + s + "%," + l + "%)";
|
}
|
|
private static Map<String, Object> stop(double offset, String color) {
|
Map<String, Object> m = new LinkedHashMap<>();
|
m.put("o", offset);
|
m.put("c", color);
|
return m;
|
}
|
|
private static String shade(int h, int s, int l, int delta) {
|
int v = Math.max(6, Math.min(94, l + delta));
|
return hsl(h, s, v);
|
}
|
|
private static String fmt(double d) {
|
return String.format(Locale.ROOT, "%.1f", d);
|
}
|
|
private static String pts(double... p) {
|
StringBuilder sb = new StringBuilder();
|
for (int i = 0; i < p.length; i += 2) {
|
if (sb.length() > 0) {
|
sb.append(' ');
|
}
|
sb.append(fmt(p[i])).append(',').append(fmt(p[i + 1]));
|
}
|
return sb.toString();
|
}
|
|
private static ShapeArt cube(int h, int s, int l) {
|
double side = 26, d = 13, cy = d / 2;
|
String fillTop = shade(h, s, l, 18);
|
String fillFront = hsl(h, s, l);
|
String fillRight = shade(h, s, l, -14);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2 + d, -cy - d, -side / 2 + d, -cy - d), fillTop, stroke));
|
faces.add(poly(pts(side / 2, -cy, side / 2, -cy + side, side / 2 + d, -cy + side - d, side / 2 + d, -cy - d), fillRight, stroke));
|
faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2, -cy + side, -side / 2, -cy + side), fillFront, stroke));
|
return new ShapeArt(faces, side + d, side + d);
|
}
|
|
private static ShapeArt box(int h, int s, int l) {
|
double w = 34, height = 22, d = 14, cy = d / 2;
|
String fillTop = shade(h, s, l, 18);
|
String fillFront = hsl(h, s, l);
|
String fillRight = shade(h, s, l, -14);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2 + d, -cy - d, -w / 2 + d, -cy - d), fillTop, stroke));
|
faces.add(poly(pts(w / 2, -cy, w / 2, -cy + height, w / 2 + d, -cy + height - d, w / 2 + d, -cy - d), fillRight, stroke));
|
faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2, -cy + height, -w / 2, -cy + height), fillFront, stroke));
|
return new ShapeArt(faces, w + d, height + d);
|
}
|
|
private static ShapeArt cylinder(int h, int s, int l) {
|
double r = 18, height = 34;
|
String fillSide = hsl(h, s, l);
|
String fillTop = shade(h, s, l, 18);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
String side = "M" + fmt(-r) + "," + fmt(-height / 2)
|
+ " L" + fmt(-r) + "," + fmt(height / 2)
|
+ " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(r) + "," + fmt(height / 2)
|
+ " L" + fmt(r) + "," + fmt(-height / 2)
|
+ " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(-r) + "," + fmt(-height / 2) + " Z";
|
faces.add(path(side, fillSide, stroke));
|
faces.add(ellipse(0, -height / 2, r, r * 0.45, fillTop, stroke, 1));
|
return new ShapeArt(faces, r * 2, height + r * 0.9);
|
}
|
|
private static ShapeArt cone(int h, int s, int l) {
|
double r = 20, height = 38;
|
double apexY = -height;
|
double cy = (-height + r * 0.45) / 2;
|
String fillBase = shade(h, s, l, -14);
|
String fillSide = hsl(h, s, l);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(ellipse(0, -cy, r, r * 0.45, fillBase, stroke, 1));
|
String side = "M" + fmt(0) + "," + fmt(apexY - cy)
|
+ " L" + fmt(-r) + "," + fmt(-cy)
|
+ " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,1," + fmt(r) + "," + fmt(-cy) + " Z";
|
faces.add(path(side, fillSide, stroke));
|
return new ShapeArt(faces, r * 2, height + r * 0.45);
|
}
|
|
private static ShapeArt sphere(int h, int s, int l) {
|
double r = 21;
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
Map<String, Object> grad = new LinkedHashMap<>();
|
grad.put("id", "g");
|
grad.put("cx", 0.35);
|
grad.put("cy", 0.32);
|
grad.put("r", 0.85);
|
List<Map<String, Object>> stops = new ArrayList<>();
|
stops.add(stop(0, shade(h, s, l, 26)));
|
stops.add(stop(0.5, hsl(h, s, l)));
|
stops.add(stop(1, shade(h, s, l, -20)));
|
grad.put("stops", stops);
|
Map<String, Object> f = new LinkedHashMap<>();
|
f.put("type", "radial");
|
f.put("cx", 0.0);
|
f.put("cy", 0.0);
|
f.put("r", r);
|
f.put("grad", grad);
|
f.put("stroke", stroke);
|
f.put("sw", 1.2);
|
faces.add(f);
|
faces.add(ellipse(-r * 0.38, -r * 0.45, r * 0.3, r * 0.2, "#ffffff", "none", 0.65));
|
faces.add(ellipse(r * 0.25, r * 0.45, r * 0.55, r * 0.2, shade(h, s, l, -32), "none", 0.45));
|
return new ShapeArt(faces, r * 2, r * 2);
|
}
|
|
private static ShapeArt triPrism(int h, int s, int l) {
|
double w = 34, height = 30, depth = 12;
|
double cy = (depth * 0.5 + height) / 2;
|
String fillTop = shade(h, s, l, 18);
|
String fillRight = hsl(h, s, l);
|
String fillLeft = shade(h, s, l, -14);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, -w / 2, -cy), fillTop, stroke));
|
faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillRight, stroke));
|
faces.add(poly(pts(0, -cy + depth * 0.5, -w / 2, -cy, -w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillLeft, stroke));
|
return new ShapeArt(faces, w, depth * 0.5 + height);
|
}
|
|
private static ShapeArt pyramid(int h, int s, int l) {
|
double half = 26, height = 34;
|
double cy = (-height + half) / 2;
|
String fillBase = shade(h, s, l, -20);
|
String fillRight = hsl(h, s, l);
|
String fillLeft = shade(h, s, l, -10);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(poly(pts(0, -cy, half, -cy + half * 0.5, 0, -cy + half, -half, -cy + half * 0.5), fillBase, stroke));
|
faces.add(poly(pts(0, -cy - height, 0, -cy, -half, -cy + half * 0.5), fillLeft, stroke));
|
faces.add(poly(pts(0, -cy - height, 0, -cy, half, -cy + half * 0.5), fillRight, stroke));
|
return new ShapeArt(faces, half * 2, height + half);
|
}
|
|
private static ShapeArt hexPrism(int h, int s, int l) {
|
double r = 22, height = 28, ry = r * 0.433;
|
double cy = ry;
|
String fillTop = shade(h, s, l, 18);
|
String fillR = hsl(h, s, l);
|
String fillF = shade(h, s, l, -12);
|
String fillL = shade(h, s, l, -22);
|
String stroke = shade(h, s, l, -30);
|
List<Map<String, Object>> faces = new ArrayList<>();
|
faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r, -cy,
|
-r * 0.5, -cy - ry, r * 0.5, -cy - ry), fillTop, stroke));
|
faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, r * 0.5, -cy + ry + height, r, -cy + height), fillR, stroke));
|
faces.add(poly(pts(r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r * 0.5, -cy + ry + height, r * 0.5, -cy + ry + height), fillF, stroke));
|
faces.add(poly(pts(-r * 0.5, -cy + ry, -r, -cy, -r, -cy + height, -r * 0.5, -cy + ry + height), fillL, stroke));
|
return new ShapeArt(faces, r * 2, ry * 2 + height);
|
}
|
}
|