diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java index 974585a..0a7c3b7 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/AuthController.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.system.controller.admin.auth; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.pojo.CommonResult; @@ -14,6 +15,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO; 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.enums.logger.LoginLogTypeEnum; +import cn.iocoder.yudao.module.system.service.auth.AdminAuthQrcodeService; import cn.iocoder.yudao.module.system.service.auth.AdminAuthService; import cn.iocoder.yudao.module.system.service.permission.MenuService; import cn.iocoder.yudao.module.system.service.permission.PermissionService; @@ -28,7 +30,6 @@ import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; -import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -44,12 +45,13 @@ import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUti @RestController @RequestMapping("/system/auth") @Validated -@Slf4j public class AuthController { @Resource private AdminAuthService authService; @Resource + private AdminAuthQrcodeService authQrcodeService; + @Resource private AdminUserService userService; @Resource private RoleService roleService; @@ -173,4 +175,29 @@ public class AuthController { return success(authService.socialLogin(reqVO)); } + // ========== 扫码登录相关 ========== + + @PostMapping("/create-qrcode") + @PermitAll + @ApiAccessLog(requestEnable = false, responseEnable = false) + @Operation(summary = "生成扫码登录二维码") + public CommonResult createAuthQrcode() { + return success(authQrcodeService.createAuthQrcode()); + } + + @PostMapping("/confirm-qrcode") + @Operation(summary = "手机确认扫码登录") + public CommonResult confirmAuthQrcode(@RequestBody @Valid AuthQrcodeConfirmReqVO reqVO) { + authQrcodeService.confirmAuthQrcode(reqVO.getQrcodeId(), getLoginUserId()); + return success(true); + } + + @PostMapping("/qrcode-login") + @PermitAll + @ApiAccessLog(requestEnable = false, responseEnable = false) + @Operation(summary = "电脑轮询领取扫码登录结果") + public CommonResult qrcodeLogin(@RequestBody @Valid AuthQrcodePollReqVO reqVO) { + return success(authService.qrcodeLogin(reqVO)); + } + } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeConfirmReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeConfirmReqVO.java new file mode 100644 index 0000000..4d3f35f --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeConfirmReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.system.controller.admin.auth.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +/** + * 手机确认扫码登录 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - 手机确认扫码登录 Request VO") +@Data +public class AuthQrcodeConfirmReqVO { + + @Schema(description = "二维码编号", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "二维码编号不能为空") + @Pattern(regexp = "[a-f0-9]{32}", message = "二维码编号格式不正确") + private String qrcodeId; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeCreateRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeCreateRespVO.java new file mode 100644 index 0000000..2f3a843 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeCreateRespVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.system.controller.admin.auth.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +/** + * 扫码登录二维码 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - 扫码登录二维码 Response VO") +@Data +public class AuthQrcodeCreateRespVO { + + @Schema(description = "二维码编号", requiredMode = Schema.RequiredMode.REQUIRED) + private String qrcodeId; + + @Schema(description = "电脑领取凭证,仅保存在生成二维码的页面", requiredMode = Schema.RequiredMode.REQUIRED) + @ToString.Exclude + private String secret; + + @Schema(description = "二维码内容,不含领取凭证", requiredMode = Schema.RequiredMode.REQUIRED) + private String qrContent; + + @Schema(description = "有效期,单位:秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "180") + private Integer expiresIn; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollReqVO.java new file mode 100644 index 0000000..65b7ba7 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.system.controller.admin.auth.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +/** + * 电脑轮询领取扫码登录结果 Request VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - 电脑轮询扫码登录 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class AuthQrcodePollReqVO extends AuthQrcodeConfirmReqVO { + + @Schema(description = "生成二维码时获得的领取凭证", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "领取凭证不能为空") + @Pattern(regexp = "[a-f0-9]{32}", message = "领取凭证格式不正确") + @ToString.Exclude + private String secret; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollRespVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollRespVO.java new file mode 100644 index 0000000..5a47159 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollRespVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.system.controller.admin.auth.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +/** + * 电脑领取扫码登录结果 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - 扫码登录结果 Response VO") +@Data +public class AuthQrcodePollRespVO { + + @Schema(description = "扫码状态,参见 AuthQrcodeStatusEnum", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; + + @Schema(description = "电脑独立登录结果,仅确认成功时返回") + @ToString.Exclude + private AuthLoginRespVO login; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java index 1cd5830..1a770d0 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/RedisKeyConstants.java @@ -10,6 +10,14 @@ import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO; public interface RedisKeyConstants { /** + * 扫码登录的临时记录 + *

+ * KEY 格式:auth_qrcode:{qrcodeId},有租户上下文时追加 :{tenantId} + * VALUE 数据类型:Hash,status、userId、secretHash;固定 180 秒过期 + */ + String AUTH_QRCODE = "auth_qrcode:%s"; + + /** * 指定部门的所有子部门编号数组的缓存 *

* KEY 格式:dept_children_ids:{id} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAO.java new file mode 100644 index 0000000..c8272ae --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAO.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.system.dal.redis.auth; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.AUTH_QRCODE; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.AUTH_QRCODE_ALREADY_CONFIRMED; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.AUTH_QRCODE_INVALID; + +/** + * 扫码登录 RedisDAO,一个 Hash 保存状态、用户编号和领取凭证摘要 + * + * @author 芋道源码 + */ +@Repository +public class AdminAuthQrcodeRedisDAO { + + public static final Duration EXPIRE_TIME = Duration.ofSeconds(180); + + /** + * 创建等待确认的二维码,并原子设置过期时间;已有编号不覆盖。 + * KEYS[1] 为二维码 key;ARGV 依次为等待状态、凭证摘要、有效秒数。 + */ + private static final DefaultRedisScript CREATE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end + redis.call('HSET', KEYS[1], 'status', ARGV[1], 'secretHash', ARGV[2]) + redis.call('EXPIRE', KEYS[1], ARGV[3]) + return 1 + """, Boolean.class); + + /** + * 等待确认时绑定用户;同一用户重复确认直接返回,其他用户不能覆盖。 + * KEYS[1] 为二维码 key;ARGV 依次为用户编号、等待状态、确认状态;不刷新 TTL。 + */ + private static final DefaultRedisScript CONFIRM_SCRIPT = new DefaultRedisScript<>(""" + local status = redis.call('HGET', KEYS[1], 'status') + if not status then return nil end + if status == ARGV[3] then return redis.call('HGET', KEYS[1], 'userId') end + if status ~= ARGV[2] then return nil end + redis.call('HSET', KEYS[1], 'userId', ARGV[1], 'status', ARGV[3]) + return ARGV[1] + """, String.class); + + /** + * 校验领取凭证,返回状态和用户;已确认时原子删除,保证只能领取一次。 + * KEYS[1] 为二维码 key;ARGV 依次为凭证摘要、等待状态、确认状态。 + */ + @SuppressWarnings("unchecked") // Redis 要求 List.class;Java 无法直接表示 List.class。 + private static final DefaultRedisScript> POLL_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('HGET', KEYS[1], 'secretHash') ~= ARGV[1] then return {} end + local status = redis.call('HGET', KEYS[1], 'status') + if status == ARGV[2] then return {status} end + if status ~= ARGV[3] then return {} end + local userId = redis.call('HGET', KEYS[1], 'userId') + if not userId then return {} end + redis.call('DEL', KEYS[1]) + return {status, userId} + """, (Class>) (Class) List.class); + + @Resource + private StringRedisTemplate stringRedisTemplate; + + public void create(String qrcodeId, String secretHash) { + Boolean created = stringRedisTemplate.execute(CREATE_SCRIPT, Collections.singletonList(formatKey(qrcodeId)), + String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), secretHash, String.valueOf(EXPIRE_TIME.toSeconds())); + if (!Boolean.TRUE.equals(created)) { + throw exception(AUTH_QRCODE_INVALID); + } + } + + public void confirm(String qrcodeId, Long userId) { + String confirmedUserId = stringRedisTemplate.execute(CONFIRM_SCRIPT, Collections.singletonList(formatKey(qrcodeId)), + String.valueOf(userId), String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), + String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus())); + // 二维码过期或已被领取时,Lua 返回 nil,对应 Java 的 null。 + if (confirmedUserId == null) { + throw exception(AUTH_QRCODE_INVALID); + } + if (!Objects.equals(confirmedUserId, String.valueOf(userId))) { + throw exception(AUTH_QRCODE_ALREADY_CONFIRMED); + } + } + + public Pair poll(String qrcodeId, String secretHash) { + List result = stringRedisTemplate.execute(POLL_SCRIPT, Collections.singletonList(formatKey(qrcodeId)), + secretHash, String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), + String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus())); + // 二维码过期、已领取或凭证不正确时,Lua 返回空列表。 + if (CollUtil.isEmpty(result)) { + throw exception(AUTH_QRCODE_INVALID); + } + if (Objects.equals(String.valueOf(result.get(0)), String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()))) { + return Pair.of(AuthQrcodeStatusEnum.WAITING, null); + } + return Pair.of(AuthQrcodeStatusEnum.CONFIRMED, Long.valueOf(String.valueOf(result.get(1)))); + } + + private static String formatKey(String qrcodeId) { + String key = String.format(AUTH_QRCODE, qrcodeId); + Long tenantId = TenantContextHolder.getTenantId(); + // 与项目租户缓存一致,使用租户后缀隔离;关闭多租户时沿用普通 key。 + return tenantId != null ? key + ":" + tenantId : key; + } + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index 7737053..c03c128 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -16,6 +16,8 @@ public interface ErrorCodeConstants { ErrorCode AUTH_THIRD_LOGIN_NOT_BIND = new ErrorCode(1_002_000_005, "未绑定账号,需要进行绑定"); ErrorCode AUTH_MOBILE_NOT_EXISTS = new ErrorCode(1_002_000_007, "手机号不存在"); ErrorCode AUTH_REGISTER_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_008, "验证码不正确,原因:{}"); + ErrorCode AUTH_QRCODE_INVALID = new ErrorCode(1_002_000_009, "二维码已失效或领取凭证不正确,请重新扫码"); + ErrorCode AUTH_QRCODE_ALREADY_CONFIRMED = new ErrorCode(1_002_000_010, "二维码已被其他用户确认,请重新扫码"); // ========== 菜单模块 1-002-001-000 ========== ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单"); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/auth/AuthQrcodeStatusEnum.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/auth/AuthQrcodeStatusEnum.java new file mode 100644 index 0000000..da92a4f --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/auth/AuthQrcodeStatusEnum.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.system.enums.auth; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 扫码登录状态 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum AuthQrcodeStatusEnum { + + WAITING(0), // 等待手机确认 + CONFIRMED(1); // 手机已确认,等待电脑领取 + + private final int status; + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/logger/LoginLogTypeEnum.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/logger/LoginLogTypeEnum.java index 4c51f91..746d612 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/logger/LoginLogTypeEnum.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/logger/LoginLogTypeEnum.java @@ -14,6 +14,7 @@ public enum LoginLogTypeEnum { LOGIN_SOCIAL(101), // 使用社交登录 LOGIN_MOBILE(103), // 使用手机登陆 LOGIN_SMS(104), // 使用短信登陆 + LOGIN_QRCODE(105), // 使用扫码登录 LOGOUT_SELF(200), // 自己主动登出 LOGOUT_DELETE(202), // 强制退出 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeService.java new file mode 100644 index 0000000..6198dd3 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeService.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.system.service.auth; + +import cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; + +/** + * 管理后台扫码登录二维码 Service + * + * @author 芋道源码 + */ +public interface AdminAuthQrcodeService { + + /** + * 生成登录二维码及仅供电脑领取的凭证 + * + * @return 二维码信息 + */ + AuthQrcodeCreateRespVO createAuthQrcode(); + + /** + * 手机确认登录二维码 + * + * @param qrcodeId 二维码编号 + * @param userId 当前登录的用户编号 + */ + void confirmAuthQrcode(String qrcodeId, Long userId); + + /** + * 轮询并一次性领取登录二维码对应的用户 + * + * @param reqVO 二维码编号及领取凭证 + * @return 二维码状态及确认的用户编号,等待确认时用户编号为空 + */ + Pair pollAuthQrcode(AuthQrcodePollReqVO reqVO); + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImpl.java new file mode 100644 index 0000000..05707b8 --- /dev/null +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImpl.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.system.service.auth; + +import cn.hutool.core.lang.Pair; +import cn.hutool.core.util.IdUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO; +import cn.iocoder.yudao.module.system.dal.redis.auth.AdminAuthQrcodeRedisDAO; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +/** + * 管理后台扫码登录二维码 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class AdminAuthQrcodeServiceImpl implements AdminAuthQrcodeService { + + /** + * 手机端识别登录二维码的前缀,不包含电脑领取凭证 + */ + private static final String QRCODE_PREFIX = "YuDaoLogin:"; + + @Resource + private AdminAuthQrcodeRedisDAO qrcodeRedisDAO; + + @Override + public AuthQrcodeCreateRespVO createAuthQrcode() { + String qrcodeId = IdUtil.simpleUUID(); + String secret = IdUtil.simpleUUID(); + qrcodeRedisDAO.create(qrcodeId, DigestUtil.sha256Hex(secret)); + return new AuthQrcodeCreateRespVO().setQrcodeId(qrcodeId).setSecret(secret) + .setQrContent(QRCODE_PREFIX + qrcodeId).setExpiresIn((int) AdminAuthQrcodeRedisDAO.EXPIRE_TIME.toSeconds()); + } + + @Override + public void confirmAuthQrcode(String qrcodeId, Long userId) { + qrcodeRedisDAO.confirm(qrcodeId, userId); + } + + @Override + public Pair pollAuthQrcode(AuthQrcodePollReqVO reqVO) { + return qrcodeRedisDAO.poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret())); + } + +} diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java index 3901e08..d48c2b6 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java @@ -32,6 +32,14 @@ public interface AdminAuthService { AuthLoginRespVO login(@Valid AuthLoginReqVO reqVO); /** + * 轮询二维码,确认后登录并签发电脑端令牌 + * + * @param reqVO 二维码编号及领取凭证 + * @return 二维码状态及登录结果 + */ + AuthQrcodePollRespVO qrcodeLogin(@Valid AuthQrcodePollReqVO reqVO); + + /** * 基于 token 退出登录 * * @param token token diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java index 52441b9..59e410b 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.system.service.auth; +import cn.hutool.core.lang.Pair; import cn.hutool.core.util.ObjectUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; @@ -17,6 +18,7 @@ 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.user.AdminUserDO; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; 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; @@ -33,7 +35,6 @@ 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,12 +51,13 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; * @author 芋道源码 */ @Service -@Slf4j public class AdminAuthServiceImpl implements AdminAuthService { @Resource private AdminUserService userService; @Resource + private AdminAuthQrcodeService authQrcodeService; + @Resource private LoginLogService loginLogService; @Resource private OAuth2TokenService oauth2TokenService; @@ -114,6 +116,23 @@ public class AdminAuthServiceImpl implements AdminAuthService { } @Override + @DataPermission(enable = false) + public AuthQrcodePollRespVO qrcodeLogin(AuthQrcodePollReqVO reqVO) { + Pair result = authQrcodeService.pollAuthQrcode(reqVO); + AuthQrcodePollRespVO respVO = new AuthQrcodePollRespVO().setStatus(result.getKey().getStatus()); + if (result.getKey() == AuthQrcodeStatusEnum.WAITING) { + return respVO; + } + // 已领取并删除二维码,登录失败或响应丢失时重新扫码。 + AdminUserDO user = userService.getUser(result.getValue()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + // 创建 Token 令牌,记录登录日志 + return respVO.setLogin(createTokenAfterLoginSuccess(user, user.getUsername(), LoginLogTypeEnum.LOGIN_QRCODE)); + } + + @Override public void sendSmsCode(AuthSmsSendReqVO reqVO) { // 如果是重置密码场景,需要校验图形验证码是否正确 if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) { @@ -310,4 +329,5 @@ public class AdminAuthServiceImpl implements AdminAuthService { userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword()); } + } diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAOTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAOTest.java new file mode 100644 index 0000000..58448ef --- /dev/null +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAOTest.java @@ -0,0 +1,167 @@ +package cn.iocoder.yudao.module.system.dal.redis.auth; + +import cn.hutool.core.lang.Pair; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseRedisUnitTest; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.*; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.AUTH_QRCODE; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * 扫码登录 Redis 原子操作测试 + * + * @author 芋道源码 + */ +@Import(AdminAuthQrcodeRedisDAO.class) +// 测试基类同时导入默认和项目 Redis 配置,使用项目配置覆盖默认 Bean。 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true") +public class AdminAuthQrcodeRedisDAOTest extends BaseRedisUnitTest { + + @Resource + private StringRedisTemplate stringRedisTemplate; + @Resource + private AdminAuthQrcodeRedisDAO dao; + + private String qrcodeId; + private String key; + + @BeforeEach + public void setUp() { + TenantContextHolder.clear(); + qrcodeId = UUID.randomUUID().toString(); + key = String.format(AUTH_QRCODE, qrcodeId); + } + + @AfterEach + public void tearDown() { + stringRedisTemplate.delete(key); + TenantContextHolder.clear(); + } + + @Test + public void testCreateAndWaiting() { + dao.create(qrcodeId, "hash"); + Map fields = stringRedisTemplate.opsForHash().entries(key); + assertEquals(Map.of("status", String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), "secretHash", "hash"), fields); + assertTrue(stringRedisTemplate.getExpire(key) > 0 && stringRedisTemplate.getExpire(key) <= 180); + assertEquals(AuthQrcodeStatusEnum.WAITING, dao.poll(qrcodeId, "hash").getKey()); + assertTrue(stringRedisTemplate.hasKey(key)); + } + + @Test + public void testConfirmDoesNotExtendTtl() { + dao.create(qrcodeId, "hash"); + stringRedisTemplate.expire(key, Duration.ofSeconds(60)); + dao.confirm(qrcodeId, 1L); + dao.confirm(qrcodeId, 1L); + assertEquals("1", stringRedisTemplate.opsForHash().get(key, "userId")); + assertEquals(String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus()), stringRedisTemplate.opsForHash().get(key, "status")); + assertTrue(stringRedisTemplate.getExpire(key) > 0 && stringRedisTemplate.getExpire(key) <= 60); + assertEquals(3, stringRedisTemplate.opsForHash().size(key)); + } + + @Test + public void testAnotherUserCannotOverwriteConfirmation() { + dao.create(qrcodeId, "hash"); + dao.confirm(qrcodeId, 1L); + assertServiceException(() -> dao.confirm(qrcodeId, 2L), AUTH_QRCODE_ALREADY_CONFIRMED); + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue()); + } + + @Test + public void testWrongSecretDoesNotConsume() { + dao.create(qrcodeId, "hash"); + assertServiceException(() -> dao.poll(qrcodeId, "wrong"), AUTH_QRCODE_INVALID); + dao.confirm(qrcodeId, 1L); + assertServiceException(() -> dao.poll(qrcodeId, "wrong"), AUTH_QRCODE_INVALID); + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue()); + assertFalse(stringRedisTemplate.hasKey(key)); + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID); + } + + @Test + public void testMissingKeyIsNotRecreated() { + assertServiceException(() -> dao.confirm(qrcodeId, 1L), AUTH_QRCODE_INVALID); + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID); + assertFalse(stringRedisTemplate.hasKey(key)); + } + + @Test + public void testCreateCannotOverwriteExistingKey() { + dao.create(qrcodeId, "hash"); + dao.confirm(qrcodeId, 1L); + assertServiceException(() -> dao.create(qrcodeId, "another"), AUTH_QRCODE_INVALID); + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue()); + } + + @Test + public void testTenantIsolation() { + // 准备参数:租户 7 创建二维码。 + TenantContextHolder.setTenantId(7L); + dao.create(qrcodeId, "hash"); + try { + // 租户 8 不能确认或领取租户 7 的二维码。 + TenantContextHolder.setTenantId(8L); + assertServiceException(() -> dao.confirm(qrcodeId, 1L), AUTH_QRCODE_INVALID); + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID); + + // 原租户仍可正常确认和领取,其他租户的请求未影响原记录。 + TenantContextHolder.setTenantId(7L); + dao.confirm(qrcodeId, 1L); + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue()); + } finally { + stringRedisTemplate.delete(key + ":7"); + } + } + + @Test + public void testConcurrentClaimOnlyOneWinner() throws Exception { + dao.create(qrcodeId, "hash"); + dao.confirm(qrcodeId, 1L); + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < 8; i++) { + futures.add(executor.submit(() -> { + start.await(); + try { + Pair result = dao.poll(qrcodeId, "hash"); + return result.getKey() == AuthQrcodeStatusEnum.CONFIRMED && result.getValue().equals(1L); + } catch (ServiceException ex) { + assertEquals(AUTH_QRCODE_INVALID.getCode(), ex.getCode()); + return false; + } + })); + } + start.countDown(); + int winners = 0; + for (Future future : futures) { + if (future.get(10, TimeUnit.SECONDS)) winners++; + } + assertEquals(1, winners); + assertFalse(stringRedisTemplate.hasKey(key)); + } finally { + executor.shutdownNow(); + } + } + +} diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImplTest.java new file mode 100644 index 0000000..d7284ed --- /dev/null +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImplTest.java @@ -0,0 +1,78 @@ +package cn.iocoder.yudao.module.system.service.auth; + +import cn.hutool.core.lang.Pair; +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO; +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO; +import cn.iocoder.yudao.module.system.dal.redis.auth.AdminAuthQrcodeRedisDAO; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link AdminAuthQrcodeServiceImpl} 的单元测试类 + * + * @author 芋道源码 + */ +@Import(AdminAuthQrcodeServiceImpl.class) +public class AdminAuthQrcodeServiceImplTest extends BaseDbUnitTest { + + @Resource + private AdminAuthQrcodeServiceImpl authQrcodeService; + @MockitoBean + private AdminAuthQrcodeRedisDAO authQrcodeRedisDAO; + + @Test + public void testCreateAuthQrcode() { + // 调用 + AuthQrcodeCreateRespVO result = authQrcodeService.createAuthQrcode(); + + // 校验:二维码不包含领取凭证,Redis 只保存凭证摘要。 + assertTrue(result.getQrcodeId().matches("[a-f0-9]{32}")); + assertTrue(result.getSecret().matches("[a-f0-9]{32}")); + assertNotEquals(result.getQrcodeId(), result.getSecret()); + assertFalse(result.getQrContent().contains(result.getSecret())); + assertEquals("YuDaoLogin:" + result.getQrcodeId(), result.getQrContent()); + assertEquals(180, result.getExpiresIn()); + verify(authQrcodeRedisDAO).create(result.getQrcodeId(), DigestUtil.sha256Hex(result.getSecret())); + } + + @Test + public void testConfirmAuthQrcode() { + // 准备参数 + String qrcodeId = randomString(); + Long userId = randomLongId(); + + // 调用 + authQrcodeService.confirmAuthQrcode(qrcodeId, userId); + + // 校验 + verify(authQrcodeRedisDAO).confirm(qrcodeId, userId); + } + + @Test + public void testPollAuthQrcode() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = new AuthQrcodePollReqVO().setSecret(randomString()); + reqVO.setQrcodeId(randomString()); + Pair expected = Pair.of(AuthQrcodeStatusEnum.CONFIRMED, randomLongId()); + when(authQrcodeRedisDAO.poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret()))) + .thenReturn(expected); + + // 调用 + Pair result = authQrcodeService.pollAuthQrcode(reqVO); + + // 校验:领取时同样使用摘要,不把原始凭证传入 Redis。 + assertEquals(expected, result); + verify(authQrcodeRedisDAO).poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret())); + } + +} diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java index 0883249..28a4f46 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java @@ -1,17 +1,21 @@ package cn.iocoder.yudao.module.system.service.auth; +import cn.hutool.core.lang.Pair; import cn.hutool.core.util.ReflectUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; 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.dal.dataobject.oauth2.OAuth2AccessTokenDO; import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum; 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.enums.social.SocialTypeEnum; import cn.iocoder.yudao.module.system.service.logger.LoginLogService; @@ -24,6 +28,7 @@ import com.anji.captcha.service.CaptchaService; import jakarta.annotation.Resource; import jakarta.validation.Validation; import jakarta.validation.Validator; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; @@ -35,7 +40,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -48,6 +53,8 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest { @MockitoBean private AdminUserService userService; @MockitoBean + private AdminAuthQrcodeService authQrcodeService; + @MockitoBean private CaptchaService captchaService; @MockitoBean private LoginLogService loginLogService; @@ -70,6 +77,11 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest { Validation.buildDefaultValidatorFactory().getValidator()); } + @AfterEach + public void tearDown() { + TenantContextHolder.clear(); + } + @Test public void testAuthenticate_success() { // 准备参数 @@ -368,6 +380,123 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest { } @Test + public void testQrcodeLogin_success() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode(); + AdminUserDO user = qrcodeUser(); + when(userService.getUser(1L)).thenReturn(user); + OAuth2AccessTokenDO token = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)); + when(oauth2TokenService.createAccessToken(1L, UserTypeEnum.ADMIN.getValue(), + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null)).thenReturn(token); + + // 调用 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO); + + // 校验 + assertEquals(AuthQrcodeStatusEnum.CONFIRMED.getStatus(), result.getStatus()); + assertEquals(token.getAccessToken(), result.getLogin().getAccessToken()); + verify(loginLogService).createLoginLog(argThat(log -> + log.getLogType().equals(LoginLogTypeEnum.LOGIN_QRCODE.getType()) + && log.getUserId().equals(1L) + && log.getResult().equals(LoginResultEnum.SUCCESS.getResult()))); + } + + @Test + public void testQrcodeLogin_disabled() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode(); + when(userService.getUser(1L)).thenReturn(qrcodeUser().setStatus(CommonStatusEnum.DISABLE.getStatus())); + + // 调用并校验:复用普通登录的禁用校验和失败日志。 + assertServiceException(() -> authService.qrcodeLogin(reqVO), AUTH_LOGIN_USER_DISABLED); + verifyNoInteractions(oauth2TokenService); + verify(loginLogService).createLoginLog(argThat(log -> + log.getLogType().equals(LoginLogTypeEnum.LOGIN_QRCODE.getType()) + && log.getResult().equals(LoginResultEnum.USER_DISABLED.getResult()))); + } + + @Test + public void testQrcodeLogin_deleted() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode(); + + // 调用并校验 + assertServiceException(() -> authService.qrcodeLogin(reqVO), USER_NOT_EXISTS); + verifyNoInteractions(oauth2TokenService); + } + + @Test + public void testQrcodeLogin_waiting() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = qrcodeRequest(); + when(authQrcodeService.pollAuthQrcode(reqVO)).thenReturn(Pair.of(AuthQrcodeStatusEnum.WAITING, null)); + + // 调用 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO); + + // 校验 + assertEquals(AuthQrcodeStatusEnum.WAITING.getStatus(), result.getStatus()); + assertNull(result.getLogin()); + verifyNoInteractions(userService, oauth2TokenService, loginLogService); + } + + @Test + public void testQrcodeLogin_currentTenant() { + // 准备参数与 mock:扫码登录沿用请求已确定的租户,不切换租户。 + TenantContextHolder.setTenantId(7L); + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode(); + when(userService.getUser(1L)).thenReturn(qrcodeUser()); + OAuth2AccessTokenDO token = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L)); + when(oauth2TokenService.createAccessToken(1L, UserTypeEnum.ADMIN.getValue(), + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null)).thenAnswer(invocation -> { + assertEquals(7L, TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + return token; + }); + + // 调用 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO); + + // 校验 + assertEquals(token.getAccessToken(), result.getLogin().getAccessToken()); + assertEquals(7L, TenantContextHolder.getTenantId()); + assertFalse(TenantContextHolder.isIgnore()); + } + + @Test + public void testQrcodeLogin_issueFailure() { + // 准备参数与 mock + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode(); + when(userService.getUser(1L)).thenReturn(qrcodeUser()); + when(oauth2TokenService.createAccessToken(any(), any(), any(), any())) + .thenThrow(new IllegalStateException("issue failed")); + + // 调用并校验:失败不重领二维码或重签令牌。 + assertThrows(IllegalStateException.class, () -> authService.qrcodeLogin(reqVO)); + verify(authQrcodeService, times(1)).pollAuthQrcode(reqVO); + verify(oauth2TokenService, times(1)).createAccessToken(any(), any(), any(), any()); + } + + private AuthQrcodePollReqVO mockConfirmedQrcode() { + AuthQrcodePollReqVO reqVO = qrcodeRequest(); + when(authQrcodeService.pollAuthQrcode(reqVO)).thenReturn(Pair.of(AuthQrcodeStatusEnum.CONFIRMED, 1L)); + return reqVO; + } + + private static AuthQrcodePollReqVO qrcodeRequest() { + AuthQrcodePollReqVO reqVO = new AuthQrcodePollReqVO().setSecret(randomString()); + reqVO.setQrcodeId(randomString()); + return reqVO; + } + + private static AdminUserDO qrcodeUser() { + AdminUserDO user = new AdminUserDO().setId(1L).setUsername("qrcode-user") + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + user.setTenantId(7L); + return user; + } + + @Test public void testRefreshToken() { // 准备参数 String refreshToken = randomString();