feat: C 端 1-1 邮箱+密码注册(免码不阻塞,带风控)
为什么:客户端要做的第一件事是账号,但现状下邮箱注册走不通 —— RegisterReqVO.code 是 @NotBlank, 而 OTP 通道现在是 LogOtpSender(只写日志、发不出去),等于注册入口堵死。本轮把"邮箱+密码"打通, 同时按口径补上免码必须带的风控。 - RegisterReqVO.code 放开非必填(PHONE 仍必填,必填性按 targetType 在服务端判断) - 邮箱注册分支:格式校验(AppIdentifier.requireEmailFormat)+ 密码强度(8-64 位且含字母数字); 传了 code 就顺手校验一次,邮箱验证能力保留但不阻塞 - 注册频控:同 IP 24h ≤5、同设备 24h ≤3(数 app_user.register_ip / register_device_id,不新建计数表),超出 429 - 顺带修一个真问题:login() 原来**无条件**把 credential 标成 verified=1, 密码登录并不证明邮箱归用户所有,会把"未验证"状态悄悄抹掉;改成只有验证码登录才置 1 - docs/api-phase1.md 补注册契约表格与残留风险(IP/设备号可伪造、OTP 只写日志、邮箱可达性未验证) 实测:免码注册成功(邮箱大小写与空格被规范化);弱密码/坏邮箱 400;重复 200001; 邮箱+密码登录成功;PHONE 不带码仍报"请先获取验证码";同设备第 4 次、同 IP 第 6 次分别 429; 免码注册后 verified=0 且经一次密码登录仍是 0。
This commit is contained in:
1 parent
414d9fd482
commit
6833e01e1f
4 files changed
+129
-6
No files matched your search
+83
-5
@@ -15,6 +15,7 @@ import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.enums.CredentialType;
|
||||
import com.fruitshorts.user.enums.OtpScene;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
@@ -30,6 +31,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* C 端账号:注册、登录、令牌轮换、登出。
|
||||
@@ -59,7 +61,21 @@ public class AppAuthService {
|
||||
public TokenRespVO register(RegisterReqVO req, String clientIp) {
|
||||
String targetType = AppIdentifier.normalizeType(req.getTargetType());
|
||||
String target = AppIdentifier.normalize(targetType, req.getTarget());
|
||||
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
|
||||
|
||||
boolean emailRegister = CredentialType.EMAIL.name().equals(targetType);
|
||||
if (emailRegister) {
|
||||
// 邮箱+密码注册:不阻塞于验证码(OTP 目前只写日志、发不出去),但必须有风控 + 密码强度
|
||||
AppIdentifier.requireEmailFormat(target);
|
||||
requirePasswordStrength(req.getPassword());
|
||||
if (StringUtils.hasText(req.getCode())) {
|
||||
// 用户主动走了邮箱验证,就把验证能力用上(保留但不阻塞)
|
||||
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
|
||||
}
|
||||
} else {
|
||||
// 手机号注册维持原状:必须验证码
|
||||
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
|
||||
}
|
||||
guardRegisterRisk(clientIp, req.getDeviceId());
|
||||
|
||||
if (findCredential(targetType, target) != null) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录");
|
||||
@@ -75,8 +91,10 @@ public class AppAuthService {
|
||||
credential.setPasswordHash(StringUtils.hasText(req.getPassword())
|
||||
? passwordEncoder.encode(req.getPassword())
|
||||
: "");
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
// 邮箱免码注册时先记"未验证"(verified=0),等用户日后点验证链接再置 1;登录不要求 verified
|
||||
boolean verified = !emailRegister || StringUtils.hasText(req.getCode());
|
||||
credential.setVerified(verified ? 1 : 0);
|
||||
credential.setVerifiedAt(verified ? LocalDateTime.now() : null);
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setFederationId("");
|
||||
credential.setAppId("");
|
||||
@@ -130,8 +148,13 @@ public class AppAuthService {
|
||||
}
|
||||
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
if (byCode) {
|
||||
// 只有「验证码登录」能证明邮箱/手机号确实归用户所有 → 才算已验证。
|
||||
// 密码登录不改验证状态:知道密码不等于能收到这个邮箱的信,否则
|
||||
// "邮箱免码注册先记未验证" 这条设计会被一次登录悄悄抹掉。
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
}
|
||||
credentialMapper.updateById(credential);
|
||||
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
@@ -276,6 +299,61 @@ public class AppAuthService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 每个 IP 24 小时内最多注册几个账号 */
|
||||
private static final int MAX_REGISTER_PER_IP_PER_DAY = 5;
|
||||
/** 每台设备 24 小时内最多注册几个账号 */
|
||||
private static final int MAX_REGISTER_PER_DEVICE_PER_DAY = 3;
|
||||
private static final int PASSWORD_MIN_LENGTH = 8;
|
||||
private static final int PASSWORD_MAX_LENGTH = 64;
|
||||
private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
|
||||
private static final Pattern HAS_DIGIT = Pattern.compile("[0-9]");
|
||||
|
||||
/**
|
||||
* 密码强度下限:8~64 位,且同时包含字母与数字。
|
||||
*
|
||||
* 有意不要求特殊字符:印尼低端机用户输入成本高,强制特殊字符会把注册转化率打下去,
|
||||
* 真正拦机器注册靠的是下面的频控。
|
||||
*/
|
||||
private void requirePasswordStrength(String password) {
|
||||
String value = password == null ? "" : password;
|
||||
if (value.length() < PASSWORD_MIN_LENGTH || value.length() > PASSWORD_MAX_LENGTH) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"密码长度需在 " + PASSWORD_MIN_LENGTH + "-" + PASSWORD_MAX_LENGTH + " 位之间");
|
||||
}
|
||||
if (!HAS_LETTER.matcher(value).find() || !HAS_DIGIT.matcher(value).find()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "密码需同时包含字母和数字");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册频控(邮箱免码注册必须带的兜底):同 IP 24h ≤5 个、同设备 24h ≤3 个。
|
||||
*
|
||||
* 直接数 `app_user` 上的 register_ip / register_device_id,不新建计数表:
|
||||
* 注册量本来就低,这两列也已经有数据,省一张表与一套清理任务。
|
||||
* 注意这是**弱风控**:IP 与设备号都是客户端/网络给的,改一下就能绕过,只挡脚本批量。
|
||||
*/
|
||||
private void guardRegisterRisk(String clientIp, String deviceId) {
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(1);
|
||||
String ip = AppIdentifier.nullSafe(clientIp);
|
||||
if (StringUtils.hasText(ip)) {
|
||||
Long byIp = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(AppUser::getRegisterIp, ip)
|
||||
.ge(AppUser::getCreatedAt, since));
|
||||
if (byIp != null && byIp >= MAX_REGISTER_PER_IP_PER_DAY) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络注册过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
String device = AppIdentifier.nullSafe(deviceId);
|
||||
if (StringUtils.hasText(device)) {
|
||||
Long byDevice = appUserMapper.selectCount(Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(AppUser::getRegisterDeviceId, device)
|
||||
.ge(AppUser::getCreatedAt, since));
|
||||
if (byDevice != null && byDevice >= MAX_REGISTER_PER_DEVICE_PER_DAY) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "该设备注册过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AppUser createUser(RegisterReqVO req, String clientIp) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.fruitshorts.user.enums.CredentialType;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 手机号/邮箱的规范化与脱敏。 */
|
||||
public final class AppIdentifier {
|
||||
@@ -60,6 +61,23 @@ public final class AppIdentifier {
|
||||
return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱格式校验。
|
||||
*
|
||||
* 只做"看起来像邮箱"的基础校验(本地部分 + 域名 + 至少一个点,域名后缀 2 位以上):
|
||||
* 真实可达性要靠发信验证,而本轮邮箱验证是**保留但不阻塞**的,所以格式不合法直接拒,
|
||||
* 格式合法但收不到信也不挡注册。
|
||||
*/
|
||||
private static final Pattern EMAIL_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9._%+\\-]{1,64}@[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?"
|
||||
+ "(\\.[A-Za-z0-9]([A-Za-z0-9\\-]{0,61}[A-Za-z0-9])?)*\\.[A-Za-z]{2,}$");
|
||||
|
||||
public static void requireEmailFormat(String email) {
|
||||
if (!StringUtils.hasText(email) || email.length() > 191 || !EMAIL_PATTERN.matcher(email).matches()) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "邮箱格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
public static String nullSafe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ public class RegisterReqVO {
|
||||
@NotBlank(message = "请输入手机号或邮箱")
|
||||
private String target;
|
||||
|
||||
@NotBlank(message = "请输入验证码")
|
||||
/**
|
||||
* 验证码:`targetType=PHONE` 时必填;`targetType=EMAIL` 时可以不传(邮箱+密码直接注册),
|
||||
* 传了就顺手校验一次(邮箱验证能力保留)。必填性在服务端按 targetType 判断。
|
||||
*/
|
||||
private String code;
|
||||
|
||||
/** 可选:同时设置密码,之后可用密码登录 */
|
||||
|
||||
Reference in new issue
Block a user