package cn.iocoder.yudao.module.system.service.auth;
|
|
import cn.hutool.core.util.ObjectUtil;
|
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils;
|
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
|
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
|
import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
|
import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO;
|
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
|
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
|
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO;
|
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
|
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
|
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
|
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
|
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
|
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
|
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
|
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
|
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
|
import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
|
import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
|
import cn.iocoder.yudao.module.system.event.SocialLoginEvent;
|
import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
|
import cn.iocoder.yudao.module.system.service.member.MemberService;
|
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
|
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
|
import cn.iocoder.yudao.module.system.service.permission.RoleService;
|
import cn.iocoder.yudao.module.system.service.social.SocialUserService;
|
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
|
import com.anji.captcha.model.common.ResponseModel;
|
import com.anji.captcha.model.vo.CaptchaVO;
|
import com.anji.captcha.service.CaptchaService;
|
import com.google.common.annotations.VisibleForTesting;
|
import jakarta.annotation.Resource;
|
import jakarta.validation.Validator;
|
import lombok.Setter;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.UUID;
|
|
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
|
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
|
|
/**
|
* Auth Service 实现类
|
*
|
* @author 超级管理员
|
*/
|
@Service
|
@Slf4j
|
public class AdminAuthServiceImpl implements AdminAuthService {
|
|
/**
|
* 企业微信社交类型,对应 {@link cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum#WECHAT_ENTERPRISE}
|
*/
|
private static final Integer WECHAT_ENTERPRISE_TYPE = 30;
|
|
/**
|
* 企业微信扫码自动注册的账号,默认绑定的角色编码。
|
*
|
* 需要在「系统管理 → 角色管理」里建好该编码的角色并勾选菜单,否则新账号登录后没有任何菜单。
|
* 编码而非角色编号:角色编号在本地/测试/生产各库不一致,写死会在换库后静默失效。
|
*/
|
private static final String WXCP_DEFAULT_ROLE_CODE = "wxcp_user";
|
|
@Resource
|
private AdminUserService userService;
|
@Resource
|
private AdminUserMapper userMapper;
|
@Resource
|
private LoginLogService loginLogService;
|
@Resource
|
private OAuth2TokenService oauth2TokenService;
|
@Resource
|
private SocialUserService socialUserService;
|
@Resource
|
private MemberService memberService;
|
@Resource
|
private Validator validator;
|
@Resource
|
private CaptchaService captchaService;
|
@Resource
|
private SmsCodeApi smsCodeApi;
|
@Resource
|
private PasswordEncoder passwordEncoder;
|
@Resource
|
private ApplicationEventPublisher eventPublisher;
|
@Resource
|
private RoleService roleService;
|
@Resource
|
private PermissionService permissionService;
|
|
/**
|
* 验证码的开关,默认为 true
|
*/
|
@Value("${yudao.captcha.enable:true}")
|
@Setter // 为了单测:开启或者关闭验证码
|
private Boolean captchaEnable;
|
|
@Override
|
public AdminUserDO authenticate(String username, String password) {
|
final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
|
// 校验账号是否存在
|
AdminUserDO user = userService.getUserByUsername(username);
|
if (user == null) {
|
createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
|
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
|
}
|
if (!userService.isPasswordMatch(password, user.getPassword())) {
|
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
|
throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
|
}
|
// 校验是否禁用
|
if (CommonStatusEnum.isDisable(user.getStatus())) {
|
createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
|
throw exception(AUTH_LOGIN_USER_DISABLED);
|
}
|
return user;
|
}
|
|
@Override
|
@DataPermission(enable = false)
|
public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
|
// 校验验证码
|
validateCaptcha(reqVO);
|
|
// 使用账号密码,进行登录
|
AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
|
|
// 如果 socialType 非空,说明需要绑定社交用户
|
if (reqVO.getSocialType() != null) {
|
socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
|
reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
|
}
|
// 创建 Token 令牌,记录登录日志
|
AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
|
|
// 发布社交登录事件(绑定新社交账号时)
|
if (reqVO.getSocialType() != null) {
|
eventPublisher.publishEvent(new SocialLoginEvent(
|
reqVO.getSocialType(), user.getId(), user.getUsername(), "账号密码绑定"));
|
}
|
|
return result;
|
}
|
|
@Override
|
public void sendSmsCode(AuthSmsSendReqVO reqVO) {
|
// 如果是重置密码场景,需要校验图形验证码是否正确
|
if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) {
|
ResponseModel response = doValidateCaptcha(reqVO);
|
if (!response.isSuccess()) {
|
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
|
}
|
}
|
|
// 登录场景,验证是否存在
|
if (userService.getUserByMobile(reqVO.getMobile()) == null) {
|
throw exception(AUTH_MOBILE_NOT_EXISTS);
|
}
|
// 发送验证码
|
smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP()));
|
}
|
|
@Override
|
public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) {
|
// 校验验证码
|
smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP()));
|
|
// 获得用户信息
|
AdminUserDO user = userService.getUserByMobile(reqVO.getMobile());
|
if (user == null) {
|
throw exception(USER_NOT_EXISTS);
|
}
|
|
// 创建 Token 令牌,记录登录日志
|
return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE);
|
}
|
|
private void createLoginLog(Long userId, String username,
|
LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
|
// 插入登录日志
|
LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
|
reqDTO.setLogType(logTypeEnum.getType());
|
reqDTO.setTraceId(TracerUtils.getTraceId());
|
reqDTO.setUserId(userId);
|
reqDTO.setUserType(getUserType().getValue());
|
reqDTO.setUsername(username);
|
reqDTO.setUserAgent(ServletUtils.getUserAgent());
|
reqDTO.setUserIp(ServletUtils.getClientIP());
|
reqDTO.setResult(loginResult.getResult());
|
loginLogService.createLoginLog(reqDTO);
|
// 更新最后登录时间
|
if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {
|
userService.updateUserLogin(userId, ServletUtils.getClientIP());
|
}
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) {
|
// 1. 使用 code 授权码,获取社交用户信息
|
SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(),
|
reqVO.getCode(), reqVO.getState());
|
if (socialUser == null) {
|
throw exception(AUTH_THIRD_LOGIN_NOT_BIND);
|
}
|
|
// 2. 判断是否已有绑定的系统用户
|
//
|
// 注意:system_social_user_bind 里的 user_id 可能指向「已被删除」的用户 ——
|
// AdminUserServiceImpl.deleteUser 只清理了角色和岗位,没有清理这张社交绑定表,
|
// 删除后绑定关系会残留成孤儿。所以这里不能只看「有没有绑定」,
|
// 还要看「绑定的人还在不在」。
|
Long bindUserId = socialUser.getUserId();
|
AdminUserDO user = bindUserId != null ? userService.getUser(bindUserId) : null;
|
|
if (user == null) {
|
// 2.1 首次登录,或原绑定用户已被删除:自动注册新用户并(改)绑定
|
//
|
// 原绑定用户已删除时必须走这条路:否则该企业成员会永久卡在「用户不存在」——
|
// 既登不进来,也不会被重新注册,是一个无法自愈的死锁。
|
// bindSocialUser 内部会先删掉该社交用户的旧绑定,所以改绑不会产生重复记录。
|
if (bindUserId != null) {
|
log.warn("[socialLogin][社交用户({}) 原绑定的用户({}) 已不存在(可能已被删除),按首次登录重新注册]",
|
socialUser.getOpenid(), bindUserId);
|
}
|
user = autoRegisterUser(socialUser, reqVO.getType());
|
// 绑定社交账号
|
socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
|
reqVO.getType(), reqVO.getCode(), reqVO.getState()));
|
log.info("[socialLogin][社交登录自动注册 userId={} username={} socialType={}]",
|
user.getId(), user.getUsername(), reqVO.getType());
|
}
|
|
// 3. 创建 Token 令牌,记录登录日志
|
AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
|
|
// 4. 发布社交登录事件(供其他模块监听,如发送企业微信通知)
|
eventPublisher.publishEvent(new SocialLoginEvent(
|
reqVO.getType(), user.getId(), user.getUsername(), "社交扫码"));
|
|
return result;
|
}
|
|
/**
|
* 社交登录自动注册用户
|
*
|
* 当用户首次通过社交账号(如企业微信)扫码且未绑定时,自动创建系统用户
|
*
|
* @param socialUser 社交用户信息
|
* @param socialType 社交类型
|
* @return 新创建的用户
|
*/
|
private AdminUserDO autoRegisterUser(SocialUserRespDTO socialUser, Integer socialType) {
|
// 1. 自动生成用户名(wx_ + 8位随机十六进制)
|
String rawUsername = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
|
// 2. 确保用户名唯一(如果冲突则重试)
|
String username = rawUsername;
|
int retry = 0;
|
while (userService.getUserByUsername(username) != null && retry < 5) {
|
username = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
retry++;
|
}
|
if (retry >= 5) {
|
throw exception(USER_USERNAME_EXISTS);
|
}
|
|
// 3. 生成随机密码
|
String password = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16);
|
|
// 4. 创建用户
|
AdminUserDO user = new AdminUserDO();
|
user.setUsername(username);
|
user.setNickname(socialUser.getNickname() != null ? socialUser.getNickname() : username);
|
user.setPassword(passwordEncoder.encode(password));
|
user.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
userMapper.insert(user);
|
|
// 5. 绑定默认角色
|
// 不绑的话账号能登录,但 get-permission-info 返回空菜单,进去一片空白;
|
// 这种"能进但什么都看不到"的状态不会报错,很难定位,所以注册时就绑上
|
bindDefaultRole(user);
|
|
return user;
|
}
|
|
/**
|
* 给自动注册的账号绑定默认角色
|
*
|
* 角色不存在时只打警告、不抛异常:一个配置遗漏不应该让扫码登录整条链路不可用,
|
* 而且警告日志里已经写明了去哪儿补。
|
*
|
* @param user 自动注册出来的用户
|
*/
|
private void bindDefaultRole(AdminUserDO user) {
|
RoleDO role = roleService.getRoleByCode(WXCP_DEFAULT_ROLE_CODE);
|
if (role == null) {
|
log.warn("[bindDefaultRole][角色编码({}) 不存在,用户({}) 将没有任何菜单权限;" +
|
"请在「系统管理 → 角色管理」新建编码为 {} 的角色并分配菜单]",
|
WXCP_DEFAULT_ROLE_CODE, user.getUsername(), WXCP_DEFAULT_ROLE_CODE);
|
return;
|
}
|
permissionService.assignUserRole(user.getId(), Set.of(role.getId()));
|
log.info("[bindDefaultRole][用户({}) 已绑定默认角色({}/{})]",
|
user.getUsername(), role.getCode(), role.getName());
|
}
|
|
@VisibleForTesting
|
void validateCaptcha(AuthLoginReqVO reqVO) {
|
ResponseModel response = doValidateCaptcha(reqVO);
|
// 校验验证码
|
if (!response.isSuccess()) {
|
// 创建登录失败日志(验证码不正确)
|
createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
|
throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
|
}
|
}
|
|
private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {
|
// 如果验证码关闭,则不进行校验
|
if (!captchaEnable) {
|
return ResponseModel.success();
|
}
|
ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
|
CaptchaVO captchaVO = new CaptchaVO();
|
captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
|
return captchaService.verification(captchaVO);
|
}
|
|
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
|
// 插入登陆日志
|
createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
|
// 创建访问令牌
|
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
|
OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
|
// 构建返回结果
|
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
|
}
|
|
@Override
|
public AuthLoginRespVO refreshToken(String refreshToken) {
|
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
|
return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
|
}
|
|
@Override
|
public void logout(String token, Integer logType) {
|
// 删除访问令牌
|
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
|
if (accessTokenDO == null) {
|
return;
|
}
|
// 删除成功,则记录登出日志
|
createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
|
}
|
|
private void createLogoutLog(Long userId, Integer userType, Integer logType) {
|
LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
|
reqDTO.setLogType(logType);
|
reqDTO.setTraceId(TracerUtils.getTraceId());
|
reqDTO.setUserId(userId);
|
reqDTO.setUserType(userType);
|
if (ObjectUtil.equal(getUserType().getValue(), userType)) {
|
reqDTO.setUsername(getUsername(userId));
|
} else {
|
reqDTO.setUsername(memberService.getMemberUserMobile(userId));
|
}
|
reqDTO.setUserAgent(ServletUtils.getUserAgent());
|
reqDTO.setUserIp(ServletUtils.getClientIP());
|
reqDTO.setResult(LoginResultEnum.SUCCESS.getResult());
|
loginLogService.createLoginLog(reqDTO);
|
}
|
|
private String getUsername(Long userId) {
|
if (userId == null) {
|
return null;
|
}
|
AdminUserDO user = userService.getUser(userId);
|
return user != null ? user.getUsername() : null;
|
}
|
|
private UserTypeEnum getUserType() {
|
return UserTypeEnum.ADMIN;
|
}
|
|
@Override
|
public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) {
|
// 1. 校验验证码
|
validateCaptcha(registerReqVO);
|
|
// 2. 校验用户名是否已存在
|
Long userId = userService.registerUser(registerReqVO);
|
|
// 3. 创建 Token 令牌,记录登录日志
|
return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
|
}
|
|
@VisibleForTesting
|
void validateCaptcha(AuthRegisterReqVO reqVO) {
|
ResponseModel response = doValidateCaptcha(reqVO);
|
// 验证不通过
|
if (!response.isSuccess()) {
|
throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
|
}
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void resetPassword(AuthResetPasswordReqVO reqVO) {
|
AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile());
|
if (userByMobile == null) {
|
throw exception(USER_MOBILE_NOT_EXISTS);
|
}
|
|
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO()
|
.setCode(reqVO.getCode())
|
.setMobile(reqVO.getMobile())
|
.setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene())
|
.setUsedIp(getClientIP())
|
);
|
|
userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
|
}
|
}
|