轻松聘AI开发
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.
 
 
 

199 lines
7.6 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.Set;
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;
private final SmsFailedAttemptRecorder failedAttemptRecorder;
@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}");
private static final Set<String> ALLOWED_PURPOSES = Set.of(
"login", "password-set", "password-reset", "company-register");
/**
* 发送验证码。
*
* <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);
}
// Redis SET NX EX 原子占位,防止并发请求同时越过发送频率限制。
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (!Boolean.TRUE.equals(redisTemplate.opsForValue()
.setIfAbsent(rateLimitKey, "1", 60, TimeUnit.SECONDS))) {
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);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 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())) {
failedAttemptRecorder.record(smsCode.getId(), now);
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
// 条件更新保证同一验证码并发只能被一个事务消费;更新 0 行表示已被其他请求消费。
if (codeRepository.consumeIfValid(smsCode.getId(), now) != 1) {
throw new BusinessException(ErrorCode.SMS_CODE_INVALID);
}
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 || !ALLOWED_PURPOSES.contains(purpose)) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"unsupported sms purpose");
}
}
}