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;
}
@@ -0,0 +1,82 @@
package org.dromara.web.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.web.domain.vo.LoginVo;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MobileSmsLoginTenantBoundaryTest {
@Test
void mobileLoginIgnoresClientTenantSelection() throws Exception {
AuthController.MobileSmsLoginBody body = new ObjectMapper().readValue("""
{"phonenumber":"13900000000","smsCode":"123456","tenantId":"tenant-b"}
""", AuthController.MobileSmsLoginBody.class);
assertEquals("13900000000", body.phonenumber());
assertEquals("123456", body.smsCode());
assertEquals("000000", AuthController.mobileTenantId());
}
@Test
@SuppressWarnings("unchecked")
void resendingSmsUsesTheSameLockAsVerification() throws Exception {
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
RBucket<String> bucket = mock(RBucket.class);
String tenantId = "000000";
String phonenumber = "13900000000";
when(redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(2, 5, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
CaptchaController.storeSmsCode(redisClient, tenantId, phonenumber, "123456");
verify(bucket).set("123456", Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
verify(lock).unlock();
}
@Test
void mobileLoginResponseUsesTheH5AuthContractOnly() throws Exception {
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken("test-access-token");
loginVo.setClientId("test-mobile-client");
loginVo.setRefreshToken("must-not-be-returned");
JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(
AuthController.MobileSmsLoginResponse.from(loginVo)
));
assertEquals("test-access-token", json.path("access_token").asText());
assertEquals("test-mobile-client", json.path("client_id").asText());
assertFalse(json.has("refresh_token"));
}
@Test
void mobileLoginResponseRejectsAnIncompleteStrategyResult() {
LoginVo loginVo = new LoginVo();
loginVo.setClientId("test-mobile-client");
assertThrows(ServiceException.class, () -> AuthController.MobileSmsLoginResponse.from(loginVo));
}
}
@@ -1,25 +1,99 @@
package org.dromara.web.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.web.service.SysLoginService;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class SmsAuthStrategyTenantTest {
@Test
void mobileSmsUserLookupKeepsTenantBoundary() throws Exception {
Path source = Path.of("src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
}
String code = Files.readString(source);
private static final String TEST_MOBILE = "13900000000";
assertTrue(code.contains(".eq(SysUser::getTenantId, tenantId)"));
assertTrue(code.contains(".eq(SysUser::getPhonenumber, phonenumber)"));
@BeforeAll
static void initializeSysUserLambdaMetadata() {
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SysUser.class);
}
}
@Test
void mobileSmsLoginUsesAppUsersAndRejectsAConflictingSystemUser() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
when(userMapper.selectVoOne(any())).thenReturn(null);
when(userMapper.exists(any())).thenReturn(true);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).insert(any(SysUser.class));
SysClientVo appClient = new SysClientVo();
appClient.setClientId(SmsCodeUtils.MOBILE_CLIENT_ID);
assertTrue(SmsAuthStrategy.isAppClient(appClient));
}
@Test
void existingAppUserIsRetainedAndWrongOtpDoesNotConsumeTheCode() throws Exception {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
appUser.setStatus("0");
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertSame(appUser, strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
@SuppressWarnings("unchecked")
RBucket<String> bucket = mock(RBucket.class);
when(redisClient.getLock(SmsCodeUtils.verificationLockKey("000000", TEST_MOBILE))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(eq(2L), eq(5L), eq(TimeUnit.SECONDS))).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
when(bucket.get()).thenReturn("123456");
assertFalse(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "000000"));
verify(bucket, never()).delete();
verify(lock).unlock();
assertTrue(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "123456"));
verify(bucket).delete();
}
@Test
void mobileSmsLoginRejectsAConflictingSystemUserEvenWhenAnAppUserExists() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
when(userMapper.exists(any())).thenReturn(true);
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).selectVoOne(any());
verify(userMapper, never()).insert(any(SysUser.class));
}
}