forked from baskbuddy/zhangshun
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
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");
|
|
}
|
|
}
|
|
}
|
|
|