You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

197 lines
7.2 KiB

package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit;
/**
* 短信验证码服务。
*
* <p>验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递;
* <strong>任何级别的日志都不得包含验证码或完整手机号</strong>。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsVerificationService {
private final SmsVerificationCodeRepository codeRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService;
private final SmsCodeSender smsCodeSender;
@Value("${app.sms.mock.code-length:6}")
private int codeLength;
@Value("${app.sms.mock.expire-seconds:300}")
private int expireSeconds;
@Value("${app.sms.mock.max-attempts:5}")
private int maxAttempts;
@Value("${app.sms.mock.lockout-seconds:300}")
private int lockoutSeconds;
private static final String RATE_LIMIT_KEY_PREFIX = "sms:rate:";
private static final String LOCKOUT_KEY_PREFIX = "sms:lockout:";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}");
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
/**
* 发送验证码。
*
* <p>发送动作委派给 {@link SmsCodeSender}:
* {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。</p>
*/
@Transactional
public void sendCode(String phone, String purpose,
String requestId, String ipAddress, String userAgent) {
validatePhone(phone);
validatePurpose(purpose);
// 检查锁定
String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) {
log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 检查频率限制(60秒内最多1次)
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) {
log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 生成验证码
String code = generateCode();
LocalDateTime now = LocalDateTime.now();
// 保存验证码记录
SmsVerificationCode smsCode = SmsVerificationCode.builder()
.phone(phone)
.code(code)
.purpose(purpose)
.attempts(0)
.maxAttempts(maxAttempts)
.expiredAt(now.plusSeconds(expireSeconds))
.used(false)
.build();
codeRepository.save(smsCode);
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code);
// 审计明细同样不得记录完整手机号
auditService.log(null, "SMS_SEND", "PHONE", null,
"SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose,
ipAddress, userAgent, requestId);
}
/**
* 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。
*/
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/**
* 验证验证码
* 按手机号+用途查询最新一条未使用记录,原子性标记已使用。
*/
@Transactional
public boolean verifyCode(String phone, String code, String purpose) {
validatePhone(phone);
validateCode(code);
validatePurpose(purpose);
String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) {
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 查询最新一条未使用的验证码(按 createdAt DESC,仅返回第一条)
java.util.Optional<SmsVerificationCode> opt =
codeRepository.streamLatestUnusedByPhoneAndPurpose(phone, purpose)
.filter(c -> !c.getUsed())
.findFirst();
if (opt.isEmpty()) {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
SmsVerificationCode smsCode = opt.get();
// 检查过期
LocalDateTime now = LocalDateTime.now();
if (smsCode.getExpiredAt().isBefore(now)) {
throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED);
}
// 检查尝试次数
if (smsCode.getAttempts() >= smsCode.getMaxAttempts()) {
redisTemplate.opsForValue().set(lockoutKey, "1", lockoutSeconds, TimeUnit.SECONDS);
throw new BusinessException(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED);
}
// 校验验证码值
if (!code.equals(smsCode.getCode())) {
smsCode.setAttempts(smsCode.getAttempts() + 1);
codeRepository.save(smsCode);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
// 原子性标记已使用并保存
smsCode.setUsed(true);
codeRepository.save(smsCode);
return true;
}
private String generateCode() {
StringBuilder sb = new StringBuilder(codeLength);
for (int i = 0; i < codeLength; i++) {
sb.append(SECURE_RANDOM.nextInt(10));
}
return sb.toString();
}
private void validatePhone(String phone) {
if (phone == null || !PHONE_PATTERN.matcher(phone).matches()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"phone must contain exactly 11 digits");
}
}
private void validateCode(String code) {
if (code == null || !CODE_PATTERN.matcher(code).matches()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"code must contain exactly 6 digits");
}
}
private void validatePurpose(String purpose) {
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"purpose is required");
}
}
}