feat(aihr): harden M0-M2 mobile training flows

This commit is contained in:
2026-07-23 21:18:13 +08:00
parent 06322c994b
commit 927458b9e0
44 changed files with 1674 additions and 119 deletions
@@ -6,6 +6,8 @@ import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
@@ -22,6 +24,7 @@ import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.*;
import org.dromara.common.encrypt.annotation.ApiEncrypt;
import org.dromara.common.json.utils.JsonUtils;
@@ -71,7 +74,6 @@ import java.util.concurrent.TimeUnit;
@RequestMapping("/auth")
public class AuthController {
private static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051";
private static final String MOBILE_GRANT_TYPE = "sms";
private final SocialProperties socialProperties;
@@ -125,25 +127,31 @@ public class AuthController {
* 移动端短信登录,复用 app 客户端和 sms 授权策略。
*/
@PostMapping("/mobile/sms-login")
public R<LoginVo> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
SysClientVo client = clientService.queryByClientId(MOBILE_CLIENT_ID);
public R<MobileSmsLoginResponse> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
SysClientVo client = clientService.queryByClientId(SmsCodeUtils.MOBILE_CLIENT_ID);
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), MOBILE_GRANT_TYPE)) {
log.info("移动端客户端id: {} 认证类型:{} 异常!.", MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
log.info("移动端客户端id: {} 认证类型:{} 异常!.", SmsCodeUtils.MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
String tenantId = StringUtils.blankToDefault(body.tenantId(), TenantConstants.DEFAULT_TENANT_ID);
// 员工端没有匿名租户选择能力;不信任客户端传入的 tenantId,避免手机号验证码被用于加入其他租户。
String tenantId = mobileTenantId();
loginService.checkTenant(tenantId);
Map<String, String> loginBody = new HashMap<>();
loginBody.put("clientId", MOBILE_CLIENT_ID);
loginBody.put("clientId", SmsCodeUtils.MOBILE_CLIENT_ID);
loginBody.put("grantType", MOBILE_GRANT_TYPE);
loginBody.put("tenantId", tenantId);
loginBody.put("phonenumber", body.phonenumber());
loginBody.put("smsCode", body.smsCode());
return R.ok(IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE));
LoginVo loginVo = IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE);
return R.ok(MobileSmsLoginResponse.from(loginVo));
}
static String mobileTenantId() {
return TenantConstants.DEFAULT_TENANT_ID;
}
/**
@@ -272,14 +280,29 @@ public class AuthController {
return R.ok(result);
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record MobileSmsLoginBody(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}")
String phonenumber,
@NotBlank(message = "{sms.code.not.blank}")
String smsCode,
String tenantId
String smsCode
) {
}
/**
* 移动端保存会话所需的最小登录响应,避免复用包含后台扩展字段的登录视图对象。
*/
public record MobileSmsLoginResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("client_id") String clientId
) {
static MobileSmsLoginResponse from(LoginVo loginVo) {
if (loginVo == null || StringUtils.isBlank(loginVo.getAccessToken()) || StringUtils.isBlank(loginVo.getClientId())) {
throw new ServiceException("移动端登录未返回认证信息");
}
return new MobileSmsLoginResponse(loginVo.getAccessToken(), loginVo.getClientId());
}
}
}
@@ -13,8 +13,10 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mail.config.properties.MailProperties;
@@ -28,6 +30,9 @@ import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.dromara.web.domain.vo.CaptchaVo;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
@@ -41,6 +46,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.awt.*;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;
/**
* 验证码操作处理
@@ -84,7 +90,6 @@ public class CaptchaController {
public R<Void> smsCode(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
boolean prodProfile = environment.acceptsProfiles(Profiles.of("prod"));
boolean fixedCodeEnabled = shouldUseFixedSmsCode(
smsDevFixedCode,
@@ -93,7 +98,7 @@ public class CaptchaController {
);
if (fixedCodeEnabled) {
log.warn("aihr.sms.dev-fixed-code 已启用,短信验证码走固定码,未真实发送短信");
RedisUtils.setCacheObject(key, smsDevFixedCode, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, smsDevFixedCode);
return R.ok();
}
if (StringUtils.isBlank(smsLoginTemplateId)) {
@@ -108,10 +113,30 @@ public class CaptchaController {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(String.valueOf(smsResponse.getData()));
}
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, code);
return R.ok();
}
static void storeSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String code) {
RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber));
boolean locked = false;
try {
locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
if (!locked) {
throw new ServiceException("验证码服务繁忙,请稍后重试");
}
RBucket<String> bucket = redisClient.getBucket(SmsCodeUtils.cacheKey(tenantId, phonenumber));
bucket.set(code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("验证码服务被中断,请重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
static boolean shouldUseFixedSmsCode(String fixedCode, boolean prodProfile, boolean prodFixedCodeEnabled) {
return StringUtils.isNotBlank(fixedCode) && (!prodProfile || prodFixedCodeEnabled);
}
@@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.SmsLoginBody;
@@ -16,6 +15,7 @@ import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
@@ -29,8 +29,13 @@ import org.dromara.system.mapper.SysUserMapper;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 短信认证策略
*
@@ -51,9 +56,10 @@ public class SmsAuthStrategy implements IAuthStrategy {
String tenantId = loginBody.getTenantId();
String phonenumber = loginBody.getPhonenumber();
String smsCode = loginBody.getSmsCode();
boolean appClient = isAppClient(client);
LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> {
loginService.checkLogin(LoginType.SMS, tenantId, phonenumber, () -> !validateSmsCode(tenantId, phonenumber, smsCode));
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber);
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber, appClient);
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
return loginService.buildLoginUser(user);
});
@@ -80,18 +86,60 @@ public class SmsAuthStrategy implements IAuthStrategy {
* 校验短信验证码
*/
private boolean validateSmsCode(String tenantId, String phonenumber, String smsCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber);
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(smsCode);
return validateSmsCode(RedisUtils.getClient(), tenantId, phonenumber, smsCode);
}
private SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>()
boolean validateSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String smsCode) {
String key = SmsCodeUtils.cacheKey(tenantId, phonenumber);
RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber));
boolean locked = false;
try {
locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
if (!locked) {
throw new UserException("验证码校验繁忙,请稍后重试");
}
RBucket<String> bucket = redisClient.getBucket(key);
String code = bucket.get();
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equals(smsCode)) {
return false;
}
bucket.delete();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new UserException("验证码校验被中断,请稍后重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
static boolean isAppClient(SysClientVo client) {
return client != null && SmsCodeUtils.MOBILE_CLIENT_ID.equals(client.getClientId());
}
SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber, boolean appClient) {
if (appClient && userMapper.exists(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getPhonenumber, phonenumber));
.eq(SysUser::getPhonenumber, phonenumber)
.and(wrapper -> wrapper.isNull(SysUser::getUserType)
.or()
.ne(SysUser::getUserType, UserType.APP_USER.getUserType())))) {
log.warn("移动端短信登录拒绝同手机号系统用户:{}", phonenumber);
throw new UserException("移动端账号未开通,请联系管理员");
}
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getPhonenumber, phonenumber);
if (appClient) {
query.eq(SysUser::getUserType, UserType.APP_USER.getUserType());
}
SysUserVo user = userMapper.selectVoOne(query);
if (ObjectUtil.isNull(user)) {
SysUser sysUser = new SysUser();
sysUser.setTenantId(tenantId);
@@ -108,6 +156,9 @@ public class SmsAuthStrategy implements IAuthStrategy {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
}
if (appClient && (user == null || !UserType.APP_USER.getUserType().equals(user.getUserType()))) {
throw new UserException("移动端账号未开通,请联系管理员");
}
return user;
}