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. 判断是否已有绑定的系统用户 Long bindUserId = socialUser.getUserId(); AdminUserDO user; if (bindUserId == null) { // 2.1 首次登录:自动注册新用户 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()); } else { // 2.2 已有绑定:直接获取用户 user = userService.getUser(bindUserId); if (user == null) { throw exception(USER_NOT_EXISTS); } } // 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()); } }