package com.dsh.account.service;
|
|
import com.dsh.account.model.ImageCaptchaVo;
|
import com.dsh.account.util.RedisUtil;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.stereotype.Service;
|
|
import javax.imageio.ImageIO;
|
import java.awt.Color;
|
import java.awt.Font;
|
import java.awt.Graphics2D;
|
import java.awt.RenderingHints;
|
import java.awt.geom.AffineTransform;
|
import java.awt.image.BufferedImage;
|
import java.io.ByteArrayOutputStream;
|
import java.io.IOException;
|
import java.security.SecureRandom;
|
import java.util.Base64;
|
import java.util.Locale;
|
import java.util.UUID;
|
|
/**
|
* 短信发送前置图形验证码服务。
|
*/
|
@Service
|
public class ImageCaptchaService {
|
|
private static final String CAPTCHA_KEY_PREFIX = "security:sms:image-captcha:";
|
private static final String CREATE_LIMIT_KEY_PREFIX = "security:sms:image-captcha:create:";
|
private static final char[] CAPTCHA_CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
|
private static final int CAPTCHA_LENGTH = 4;
|
private static final int IMAGE_WIDTH = 120;
|
private static final int IMAGE_HEIGHT = 44;
|
|
private final SecureRandom secureRandom = new SecureRandom();
|
|
@Autowired
|
private RedisUtil redisUtil;
|
|
@Value("${security.sms.image-captcha-expire-seconds:120}")
|
private int expireSeconds;
|
|
@Value("${security.sms.image-captcha-max-per-minute:10}")
|
private int maxPerMinute;
|
|
/**
|
* 创建与手机号、短信用途绑定的图形验证码。
|
*
|
* @return 达到生成频率上限时返回null
|
*/
|
public ImageCaptchaVo create(String phone, Integer type, String clientIp) throws IOException {
|
if (!redisUtil.tryAcquireFixedWindow(
|
CREATE_LIMIT_KEY_PREFIX + clientIp,
|
maxPerMinute,
|
60)) {
|
return null;
|
}
|
|
String captchaId = UUID.randomUUID().toString().replace("-", "");
|
String captchaCode = randomCode();
|
redisUtil.setStrValue(
|
CAPTCHA_KEY_PREFIX + captchaId,
|
captchaValue(phone, type, captchaCode),
|
expireSeconds
|
);
|
return new ImageCaptchaVo(captchaId, renderImage(captchaCode), expireSeconds);
|
}
|
|
/**
|
* 原子校验并删除验证码。无论输入是否正确,每个验证码都只能提交一次。
|
*/
|
public boolean verifyAndConsume(String captchaId, String captchaCode, String phone, Integer type) {
|
if (captchaId == null || captchaCode == null) {
|
return false;
|
}
|
|
String normalizedId = captchaId.trim().toLowerCase(Locale.ROOT);
|
String normalizedCode = captchaCode.trim().toUpperCase(Locale.ROOT);
|
if (!normalizedId.matches("[0-9a-f]{32}") || !normalizedCode.matches("[0-9A-Z]{4}")) {
|
return false;
|
}
|
|
return redisUtil.consumeIfEquals(
|
CAPTCHA_KEY_PREFIX + normalizedId,
|
captchaValue(phone, type, normalizedCode)
|
);
|
}
|
|
private String randomCode() {
|
StringBuilder code = new StringBuilder(CAPTCHA_LENGTH);
|
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
|
code.append(CAPTCHA_CHARS[secureRandom.nextInt(CAPTCHA_CHARS.length)]);
|
}
|
return code.toString();
|
}
|
|
private String renderImage(String code) throws IOException {
|
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
Graphics2D graphics = image.createGraphics();
|
try {
|
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
graphics.setColor(new Color(245, 247, 250));
|
graphics.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
drawNoise(graphics);
|
drawCode(graphics, code);
|
} finally {
|
graphics.dispose();
|
}
|
|
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
ImageIO.write(image, "png", output);
|
return "data:image/png;base64," + Base64.getEncoder().encodeToString(output.toByteArray());
|
}
|
}
|
|
private void drawNoise(Graphics2D graphics) {
|
for (int i = 0; i < 7; i++) {
|
graphics.setColor(randomColor(150, 210));
|
graphics.drawLine(
|
secureRandom.nextInt(IMAGE_WIDTH),
|
secureRandom.nextInt(IMAGE_HEIGHT),
|
secureRandom.nextInt(IMAGE_WIDTH),
|
secureRandom.nextInt(IMAGE_HEIGHT)
|
);
|
}
|
for (int i = 0; i < 45; i++) {
|
graphics.setColor(randomColor(120, 220));
|
graphics.fillOval(secureRandom.nextInt(IMAGE_WIDTH), secureRandom.nextInt(IMAGE_HEIGHT), 2, 2);
|
}
|
}
|
|
private void drawCode(Graphics2D graphics, String code) {
|
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 27));
|
for (int i = 0; i < code.length(); i++) {
|
Graphics2D characterGraphics = (Graphics2D) graphics.create();
|
try {
|
characterGraphics.setColor(randomColor(25, 115));
|
double angle = (secureRandom.nextDouble() - 0.5D) * 0.45D;
|
int x = 12 + i * 26;
|
int y = 31 + secureRandom.nextInt(6) - 3;
|
characterGraphics.setTransform(AffineTransform.getRotateInstance(angle, x + 9, y - 10));
|
characterGraphics.drawString(String.valueOf(code.charAt(i)), x, y);
|
} finally {
|
characterGraphics.dispose();
|
}
|
}
|
}
|
|
private Color randomColor(int min, int max) {
|
int bound = max - min + 1;
|
return new Color(
|
min + secureRandom.nextInt(bound),
|
min + secureRandom.nextInt(bound),
|
min + secureRandom.nextInt(bound)
|
);
|
}
|
|
private String captchaValue(String phone, Integer type, String code) {
|
return phone + ":" + type + ":" + code;
|
}
|
}
|