Commit 94c6863ebc8a861c20aeecedde211e0dc9d1ac76

Authored by YunaiV
1 parent ee2cfb9c

feat(system): 支持手机确认扫码登录管理后台

Showing 17 changed files with 768 additions and 5 deletions
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;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
5 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
6 7 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
7 8 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
... ... @@ -14,6 +15,7 @@ import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
14 15 import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
15 16 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
16 17 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
  18 +import cn.iocoder.yudao.module.system.service.auth.AdminAuthQrcodeService;
17 19 import cn.iocoder.yudao.module.system.service.auth.AdminAuthService;
18 20 import cn.iocoder.yudao.module.system.service.permission.MenuService;
19 21 import cn.iocoder.yudao.module.system.service.permission.PermissionService;
... ... @@ -28,7 +30,6 @@ import jakarta.annotation.Resource;
28 30 import jakarta.annotation.security.PermitAll;
29 31 import jakarta.servlet.http.HttpServletRequest;
30 32 import jakarta.validation.Valid;
31   -import lombok.extern.slf4j.Slf4j;
32 33 import org.springframework.validation.annotation.Validated;
33 34 import org.springframework.web.bind.annotation.*;
34 35  
... ... @@ -44,12 +45,13 @@ import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUti
44 45 @RestController
45 46 @RequestMapping("/system/auth")
46 47 @Validated
47   -@Slf4j
48 48 public class AuthController {
49 49  
50 50 @Resource
51 51 private AdminAuthService authService;
52 52 @Resource
  53 + private AdminAuthQrcodeService authQrcodeService;
  54 + @Resource
53 55 private AdminUserService userService;
54 56 @Resource
55 57 private RoleService roleService;
... ... @@ -173,4 +175,29 @@ public class AuthController {
173 175 return success(authService.socialLogin(reqVO));
174 176 }
175 177  
  178 + // ========== 扫码登录相关 ==========
  179 +
  180 + @PostMapping("/create-qrcode")
  181 + @PermitAll
  182 + @ApiAccessLog(requestEnable = false, responseEnable = false)
  183 + @Operation(summary = "生成扫码登录二维码")
  184 + public CommonResult<AuthQrcodeCreateRespVO> createAuthQrcode() {
  185 + return success(authQrcodeService.createAuthQrcode());
  186 + }
  187 +
  188 + @PostMapping("/confirm-qrcode")
  189 + @Operation(summary = "手机确认扫码登录")
  190 + public CommonResult<Boolean> confirmAuthQrcode(@RequestBody @Valid AuthQrcodeConfirmReqVO reqVO) {
  191 + authQrcodeService.confirmAuthQrcode(reqVO.getQrcodeId(), getLoginUserId());
  192 + return success(true);
  193 + }
  194 +
  195 + @PostMapping("/qrcode-login")
  196 + @PermitAll
  197 + @ApiAccessLog(requestEnable = false, responseEnable = false)
  198 + @Operation(summary = "电脑轮询领取扫码登录结果")
  199 + public CommonResult<AuthQrcodePollRespVO> qrcodeLogin(@RequestBody @Valid AuthQrcodePollReqVO reqVO) {
  200 + return success(authService.qrcodeLogin(reqVO));
  201 + }
  202 +
176 203 }
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeConfirmReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotEmpty;
  5 +import jakarta.validation.constraints.Pattern;
  6 +import lombok.Data;
  7 +
  8 +/**
  9 + * 手机确认扫码登录 Request VO
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Schema(description = "管理后台 - 手机确认扫码登录 Request VO")
  14 +@Data
  15 +public class AuthQrcodeConfirmReqVO {
  16 +
  17 + @Schema(description = "二维码编号", requiredMode = Schema.RequiredMode.REQUIRED)
  18 + @NotEmpty(message = "二维码编号不能为空")
  19 + @Pattern(regexp = "[a-f0-9]{32}", message = "二维码编号格式不正确")
  20 + private String qrcodeId;
  21 +
  22 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodeCreateRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +import lombok.ToString;
  6 +
  7 +/**
  8 + * 扫码登录二维码 Response VO
  9 + *
  10 + * @author 芋道源码
  11 + */
  12 +@Schema(description = "管理后台 - 扫码登录二维码 Response VO")
  13 +@Data
  14 +public class AuthQrcodeCreateRespVO {
  15 +
  16 + @Schema(description = "二维码编号", requiredMode = Schema.RequiredMode.REQUIRED)
  17 + private String qrcodeId;
  18 +
  19 + @Schema(description = "电脑领取凭证,仅保存在生成二维码的页面", requiredMode = Schema.RequiredMode.REQUIRED)
  20 + @ToString.Exclude
  21 + private String secret;
  22 +
  23 + @Schema(description = "二维码内容,不含领取凭证", requiredMode = Schema.RequiredMode.REQUIRED)
  24 + private String qrContent;
  25 +
  26 + @Schema(description = "有效期,单位:秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "180")
  27 + private Integer expiresIn;
  28 +
  29 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotEmpty;
  5 +import jakarta.validation.constraints.Pattern;
  6 +import lombok.Data;
  7 +import lombok.EqualsAndHashCode;
  8 +import lombok.ToString;
  9 +
  10 +/**
  11 + * 电脑轮询领取扫码登录结果 Request VO
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Schema(description = "管理后台 - 电脑轮询扫码登录 Request VO")
  16 +@Data
  17 +@EqualsAndHashCode(callSuper = true)
  18 +public class AuthQrcodePollReqVO extends AuthQrcodeConfirmReqVO {
  19 +
  20 + @Schema(description = "生成二维码时获得的领取凭证", requiredMode = Schema.RequiredMode.REQUIRED)
  21 + @NotEmpty(message = "领取凭证不能为空")
  22 + @Pattern(regexp = "[a-f0-9]{32}", message = "领取凭证格式不正确")
  23 + @ToString.Exclude
  24 + private String secret;
  25 +
  26 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthQrcodePollRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.controller.admin.auth.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +import lombok.ToString;
  6 +
  7 +/**
  8 + * 电脑领取扫码登录结果 Response VO
  9 + *
  10 + * @author 芋道源码
  11 + */
  12 +@Schema(description = "管理后台 - 扫码登录结果 Response VO")
  13 +@Data
  14 +public class AuthQrcodePollRespVO {
  15 +
  16 + @Schema(description = "扫码状态,参见 AuthQrcodeStatusEnum", requiredMode = Schema.RequiredMode.REQUIRED)
  17 + private Integer status;
  18 +
  19 + @Schema(description = "电脑独立登录结果,仅确认成功时返回")
  20 + @ToString.Exclude
  21 + private AuthLoginRespVO login;
  22 +
  23 +}
... ...
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;
10 10 public interface RedisKeyConstants {
11 11  
12 12 /**
  13 + * 扫码登录的临时记录
  14 + * <p>
  15 + * KEY 格式:auth_qrcode:{qrcodeId},有租户上下文时追加 :{tenantId}
  16 + * VALUE 数据类型:Hash,status、userId、secretHash;固定 180 秒过期
  17 + */
  18 + String AUTH_QRCODE = "auth_qrcode:%s";
  19 +
  20 + /**
13 21 * 指定部门的所有子部门编号数组的缓存
14 22 * <p>
15 23 * KEY 格式:dept_children_ids:{id}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.dal.redis.auth;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Pair;
  5 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
  6 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
  7 +import jakarta.annotation.Resource;
  8 +import org.springframework.data.redis.core.StringRedisTemplate;
  9 +import org.springframework.data.redis.core.script.DefaultRedisScript;
  10 +import org.springframework.stereotype.Repository;
  11 +
  12 +import java.time.Duration;
  13 +import java.util.Collections;
  14 +import java.util.List;
  15 +import java.util.Objects;
  16 +
  17 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  18 +import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.AUTH_QRCODE;
  19 +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.AUTH_QRCODE_ALREADY_CONFIRMED;
  20 +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.AUTH_QRCODE_INVALID;
  21 +
  22 +/**
  23 + * 扫码登录 RedisDAO,一个 Hash 保存状态、用户编号和领取凭证摘要
  24 + *
  25 + * @author 芋道源码
  26 + */
  27 +@Repository
  28 +public class AdminAuthQrcodeRedisDAO {
  29 +
  30 + public static final Duration EXPIRE_TIME = Duration.ofSeconds(180);
  31 +
  32 + /**
  33 + * 创建等待确认的二维码,并原子设置过期时间;已有编号不覆盖。
  34 + * KEYS[1] 为二维码 key;ARGV 依次为等待状态、凭证摘要、有效秒数。
  35 + */
  36 + private static final DefaultRedisScript<Boolean> CREATE_SCRIPT = new DefaultRedisScript<>("""
  37 + if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
  38 + redis.call('HSET', KEYS[1], 'status', ARGV[1], 'secretHash', ARGV[2])
  39 + redis.call('EXPIRE', KEYS[1], ARGV[3])
  40 + return 1
  41 + """, Boolean.class);
  42 +
  43 + /**
  44 + * 等待确认时绑定用户;同一用户重复确认直接返回,其他用户不能覆盖。
  45 + * KEYS[1] 为二维码 key;ARGV 依次为用户编号、等待状态、确认状态;不刷新 TTL。
  46 + */
  47 + private static final DefaultRedisScript<String> CONFIRM_SCRIPT = new DefaultRedisScript<>("""
  48 + local status = redis.call('HGET', KEYS[1], 'status')
  49 + if not status then return nil end
  50 + if status == ARGV[3] then return redis.call('HGET', KEYS[1], 'userId') end
  51 + if status ~= ARGV[2] then return nil end
  52 + redis.call('HSET', KEYS[1], 'userId', ARGV[1], 'status', ARGV[3])
  53 + return ARGV[1]
  54 + """, String.class);
  55 +
  56 + /**
  57 + * 校验领取凭证,返回状态和用户;已确认时原子删除,保证只能领取一次。
  58 + * KEYS[1] 为二维码 key;ARGV 依次为凭证摘要、等待状态、确认状态。
  59 + */
  60 + @SuppressWarnings("unchecked") // Redis 要求 List.class;Java 无法直接表示 List<?>.class。
  61 + private static final DefaultRedisScript<List<?>> POLL_SCRIPT = new DefaultRedisScript<>("""
  62 + if redis.call('HGET', KEYS[1], 'secretHash') ~= ARGV[1] then return {} end
  63 + local status = redis.call('HGET', KEYS[1], 'status')
  64 + if status == ARGV[2] then return {status} end
  65 + if status ~= ARGV[3] then return {} end
  66 + local userId = redis.call('HGET', KEYS[1], 'userId')
  67 + if not userId then return {} end
  68 + redis.call('DEL', KEYS[1])
  69 + return {status, userId}
  70 + """, (Class<List<?>>) (Class<?>) List.class);
  71 +
  72 + @Resource
  73 + private StringRedisTemplate stringRedisTemplate;
  74 +
  75 + public void create(String qrcodeId, String secretHash) {
  76 + Boolean created = stringRedisTemplate.execute(CREATE_SCRIPT, Collections.singletonList(formatKey(qrcodeId)),
  77 + String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), secretHash, String.valueOf(EXPIRE_TIME.toSeconds()));
  78 + if (!Boolean.TRUE.equals(created)) {
  79 + throw exception(AUTH_QRCODE_INVALID);
  80 + }
  81 + }
  82 +
  83 + public void confirm(String qrcodeId, Long userId) {
  84 + String confirmedUserId = stringRedisTemplate.execute(CONFIRM_SCRIPT, Collections.singletonList(formatKey(qrcodeId)),
  85 + String.valueOf(userId), String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()),
  86 + String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus()));
  87 + // 二维码过期或已被领取时,Lua 返回 nil,对应 Java 的 null。
  88 + if (confirmedUserId == null) {
  89 + throw exception(AUTH_QRCODE_INVALID);
  90 + }
  91 + if (!Objects.equals(confirmedUserId, String.valueOf(userId))) {
  92 + throw exception(AUTH_QRCODE_ALREADY_CONFIRMED);
  93 + }
  94 + }
  95 +
  96 + public Pair<AuthQrcodeStatusEnum, Long> poll(String qrcodeId, String secretHash) {
  97 + List<?> result = stringRedisTemplate.execute(POLL_SCRIPT, Collections.singletonList(formatKey(qrcodeId)),
  98 + secretHash, String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()),
  99 + String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus()));
  100 + // 二维码过期、已领取或凭证不正确时,Lua 返回空列表。
  101 + if (CollUtil.isEmpty(result)) {
  102 + throw exception(AUTH_QRCODE_INVALID);
  103 + }
  104 + if (Objects.equals(String.valueOf(result.get(0)), String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()))) {
  105 + return Pair.of(AuthQrcodeStatusEnum.WAITING, null);
  106 + }
  107 + return Pair.of(AuthQrcodeStatusEnum.CONFIRMED, Long.valueOf(String.valueOf(result.get(1))));
  108 + }
  109 +
  110 + private static String formatKey(String qrcodeId) {
  111 + String key = String.format(AUTH_QRCODE, qrcodeId);
  112 + Long tenantId = TenantContextHolder.getTenantId();
  113 + // 与项目租户缓存一致,使用租户后缀隔离;关闭多租户时沿用普通 key。
  114 + return tenantId != null ? key + ":" + tenantId : key;
  115 + }
  116 +
  117 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
... ... @@ -16,6 +16,8 @@ public interface ErrorCodeConstants {
16 16 ErrorCode AUTH_THIRD_LOGIN_NOT_BIND = new ErrorCode(1_002_000_005, "未绑定账号,需要进行绑定");
17 17 ErrorCode AUTH_MOBILE_NOT_EXISTS = new ErrorCode(1_002_000_007, "手机号不存在");
18 18 ErrorCode AUTH_REGISTER_CAPTCHA_CODE_ERROR = new ErrorCode(1_002_000_008, "验证码不正确,原因:{}");
  19 + ErrorCode AUTH_QRCODE_INVALID = new ErrorCode(1_002_000_009, "二维码已失效或领取凭证不正确,请重新扫码");
  20 + ErrorCode AUTH_QRCODE_ALREADY_CONFIRMED = new ErrorCode(1_002_000_010, "二维码已被其他用户确认,请重新扫码");
19 21  
20 22 // ========== 菜单模块 1-002-001-000 ==========
21 23 ErrorCode MENU_NAME_DUPLICATE = new ErrorCode(1_002_001_000, "已经存在该名字的菜单");
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/auth/AuthQrcodeStatusEnum.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.enums.auth;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import lombok.Getter;
  5 +
  6 +/**
  7 + * 扫码登录状态
  8 + *
  9 + * @author 芋道源码
  10 + */
  11 +@Getter
  12 +@AllArgsConstructor
  13 +public enum AuthQrcodeStatusEnum {
  14 +
  15 + WAITING(0), // 等待手机确认
  16 + CONFIRMED(1); // 手机已确认,等待电脑领取
  17 +
  18 + private final int status;
  19 +
  20 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/logger/LoginLogTypeEnum.java
... ... @@ -14,6 +14,7 @@ public enum LoginLogTypeEnum {
14 14 LOGIN_SOCIAL(101), // 使用社交登录
15 15 LOGIN_MOBILE(103), // 使用手机登陆
16 16 LOGIN_SMS(104), // 使用短信登陆
  17 + LOGIN_QRCODE(105), // 使用扫码登录
17 18  
18 19 LOGOUT_SELF(200), // 自己主动登出
19 20 LOGOUT_DELETE(202), // 强制退出
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.service.auth;
  2 +
  3 +import cn.hutool.core.lang.Pair;
  4 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO;
  5 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO;
  6 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
  7 +
  8 +/**
  9 + * 管理后台扫码登录二维码 Service
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +public interface AdminAuthQrcodeService {
  14 +
  15 + /**
  16 + * 生成登录二维码及仅供电脑领取的凭证
  17 + *
  18 + * @return 二维码信息
  19 + */
  20 + AuthQrcodeCreateRespVO createAuthQrcode();
  21 +
  22 + /**
  23 + * 手机确认登录二维码
  24 + *
  25 + * @param qrcodeId 二维码编号
  26 + * @param userId 当前登录的用户编号
  27 + */
  28 + void confirmAuthQrcode(String qrcodeId, Long userId);
  29 +
  30 + /**
  31 + * 轮询并一次性领取登录二维码对应的用户
  32 + *
  33 + * @param reqVO 二维码编号及领取凭证
  34 + * @return 二维码状态及确认的用户编号,等待确认时用户编号为空
  35 + */
  36 + Pair<AuthQrcodeStatusEnum, Long> pollAuthQrcode(AuthQrcodePollReqVO reqVO);
  37 +
  38 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.service.auth;
  2 +
  3 +import cn.hutool.core.lang.Pair;
  4 +import cn.hutool.core.util.IdUtil;
  5 +import cn.hutool.crypto.digest.DigestUtil;
  6 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO;
  7 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO;
  8 +import cn.iocoder.yudao.module.system.dal.redis.auth.AdminAuthQrcodeRedisDAO;
  9 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
  10 +import jakarta.annotation.Resource;
  11 +import org.springframework.stereotype.Service;
  12 +
  13 +/**
  14 + * 管理后台扫码登录二维码 Service 实现类
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Service
  19 +public class AdminAuthQrcodeServiceImpl implements AdminAuthQrcodeService {
  20 +
  21 + /**
  22 + * 手机端识别登录二维码的前缀,不包含电脑领取凭证
  23 + */
  24 + private static final String QRCODE_PREFIX = "YuDaoLogin:";
  25 +
  26 + @Resource
  27 + private AdminAuthQrcodeRedisDAO qrcodeRedisDAO;
  28 +
  29 + @Override
  30 + public AuthQrcodeCreateRespVO createAuthQrcode() {
  31 + String qrcodeId = IdUtil.simpleUUID();
  32 + String secret = IdUtil.simpleUUID();
  33 + qrcodeRedisDAO.create(qrcodeId, DigestUtil.sha256Hex(secret));
  34 + return new AuthQrcodeCreateRespVO().setQrcodeId(qrcodeId).setSecret(secret)
  35 + .setQrContent(QRCODE_PREFIX + qrcodeId).setExpiresIn((int) AdminAuthQrcodeRedisDAO.EXPIRE_TIME.toSeconds());
  36 + }
  37 +
  38 + @Override
  39 + public void confirmAuthQrcode(String qrcodeId, Long userId) {
  40 + qrcodeRedisDAO.confirm(qrcodeId, userId);
  41 + }
  42 +
  43 + @Override
  44 + public Pair<AuthQrcodeStatusEnum, Long> pollAuthQrcode(AuthQrcodePollReqVO reqVO) {
  45 + return qrcodeRedisDAO.poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret()));
  46 + }
  47 +
  48 +}
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthService.java
... ... @@ -32,6 +32,14 @@ public interface AdminAuthService {
32 32 AuthLoginRespVO login(@Valid AuthLoginReqVO reqVO);
33 33  
34 34 /**
  35 + * 轮询二维码,确认后登录并签发电脑端令牌
  36 + *
  37 + * @param reqVO 二维码编号及领取凭证
  38 + * @return 二维码状态及登录结果
  39 + */
  40 + AuthQrcodePollRespVO qrcodeLogin(@Valid AuthQrcodePollReqVO reqVO);
  41 +
  42 + /**
35 43 * 基于 token 退出登录
36 44 *
37 45 * @param token token
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImpl.java
1 1 package cn.iocoder.yudao.module.system.service.auth;
2 2  
  3 +import cn.hutool.core.lang.Pair;
3 4 import cn.hutool.core.util.ObjectUtil;
4 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 6 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
... ... @@ -17,6 +18,7 @@ import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
17 18 import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
18 19 import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
19 20 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
  21 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
20 22 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
21 23 import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
22 24 import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
... ... @@ -33,7 +35,6 @@ import com.google.common.annotations.VisibleForTesting;
33 35 import jakarta.annotation.Resource;
34 36 import jakarta.validation.Validator;
35 37 import lombok.Setter;
36   -import lombok.extern.slf4j.Slf4j;
37 38 import org.springframework.beans.factory.annotation.Value;
38 39 import org.springframework.stereotype.Service;
39 40 import org.springframework.transaction.annotation.Transactional;
... ... @@ -50,12 +51,13 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
50 51 * @author 芋道源码
51 52 */
52 53 @Service
53   -@Slf4j
54 54 public class AdminAuthServiceImpl implements AdminAuthService {
55 55  
56 56 @Resource
57 57 private AdminUserService userService;
58 58 @Resource
  59 + private AdminAuthQrcodeService authQrcodeService;
  60 + @Resource
59 61 private LoginLogService loginLogService;
60 62 @Resource
61 63 private OAuth2TokenService oauth2TokenService;
... ... @@ -114,6 +116,23 @@ public class AdminAuthServiceImpl implements AdminAuthService {
114 116 }
115 117  
116 118 @Override
  119 + @DataPermission(enable = false)
  120 + public AuthQrcodePollRespVO qrcodeLogin(AuthQrcodePollReqVO reqVO) {
  121 + Pair<AuthQrcodeStatusEnum, Long> result = authQrcodeService.pollAuthQrcode(reqVO);
  122 + AuthQrcodePollRespVO respVO = new AuthQrcodePollRespVO().setStatus(result.getKey().getStatus());
  123 + if (result.getKey() == AuthQrcodeStatusEnum.WAITING) {
  124 + return respVO;
  125 + }
  126 + // 已领取并删除二维码,登录失败或响应丢失时重新扫码。
  127 + AdminUserDO user = userService.getUser(result.getValue());
  128 + if (user == null) {
  129 + throw exception(USER_NOT_EXISTS);
  130 + }
  131 + // 创建 Token 令牌,记录登录日志
  132 + return respVO.setLogin(createTokenAfterLoginSuccess(user, user.getUsername(), LoginLogTypeEnum.LOGIN_QRCODE));
  133 + }
  134 +
  135 + @Override
117 136 public void sendSmsCode(AuthSmsSendReqVO reqVO) {
118 137 // 如果是重置密码场景,需要校验图形验证码是否正确
119 138 if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) {
... ... @@ -310,4 +329,5 @@ public class AdminAuthServiceImpl implements AdminAuthService {
310 329  
311 330 userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
312 331 }
  332 +
313 333 }
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/dal/redis/auth/AdminAuthQrcodeRedisDAOTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.dal.redis.auth;
  2 +
  3 +import cn.hutool.core.lang.Pair;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseRedisUnitTest;
  6 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
  7 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
  8 +import jakarta.annotation.Resource;
  9 +import org.junit.jupiter.api.AfterEach;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.context.annotation.Import;
  13 +import org.springframework.data.redis.core.StringRedisTemplate;
  14 +import org.springframework.test.context.TestPropertySource;
  15 +
  16 +import java.time.Duration;
  17 +import java.util.ArrayList;
  18 +import java.util.List;
  19 +import java.util.Map;
  20 +import java.util.UUID;
  21 +import java.util.concurrent.*;
  22 +
  23 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  24 +import static cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants.AUTH_QRCODE;
  25 +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
  26 +import static org.junit.jupiter.api.Assertions.*;
  27 +
  28 +/**
  29 + * 扫码登录 Redis 原子操作测试
  30 + *
  31 + * @author 芋道源码
  32 + */
  33 +@Import(AdminAuthQrcodeRedisDAO.class)
  34 +// 测试基类同时导入默认和项目 Redis 配置,使用项目配置覆盖默认 Bean。
  35 +@TestPropertySource(properties = "spring.main.allow-bean-definition-overriding=true")
  36 +public class AdminAuthQrcodeRedisDAOTest extends BaseRedisUnitTest {
  37 +
  38 + @Resource
  39 + private StringRedisTemplate stringRedisTemplate;
  40 + @Resource
  41 + private AdminAuthQrcodeRedisDAO dao;
  42 +
  43 + private String qrcodeId;
  44 + private String key;
  45 +
  46 + @BeforeEach
  47 + public void setUp() {
  48 + TenantContextHolder.clear();
  49 + qrcodeId = UUID.randomUUID().toString();
  50 + key = String.format(AUTH_QRCODE, qrcodeId);
  51 + }
  52 +
  53 + @AfterEach
  54 + public void tearDown() {
  55 + stringRedisTemplate.delete(key);
  56 + TenantContextHolder.clear();
  57 + }
  58 +
  59 + @Test
  60 + public void testCreateAndWaiting() {
  61 + dao.create(qrcodeId, "hash");
  62 + Map<Object, Object> fields = stringRedisTemplate.opsForHash().entries(key);
  63 + assertEquals(Map.of("status", String.valueOf(AuthQrcodeStatusEnum.WAITING.getStatus()), "secretHash", "hash"), fields);
  64 + assertTrue(stringRedisTemplate.getExpire(key) > 0 && stringRedisTemplate.getExpire(key) <= 180);
  65 + assertEquals(AuthQrcodeStatusEnum.WAITING, dao.poll(qrcodeId, "hash").getKey());
  66 + assertTrue(stringRedisTemplate.hasKey(key));
  67 + }
  68 +
  69 + @Test
  70 + public void testConfirmDoesNotExtendTtl() {
  71 + dao.create(qrcodeId, "hash");
  72 + stringRedisTemplate.expire(key, Duration.ofSeconds(60));
  73 + dao.confirm(qrcodeId, 1L);
  74 + dao.confirm(qrcodeId, 1L);
  75 + assertEquals("1", stringRedisTemplate.opsForHash().get(key, "userId"));
  76 + assertEquals(String.valueOf(AuthQrcodeStatusEnum.CONFIRMED.getStatus()), stringRedisTemplate.opsForHash().get(key, "status"));
  77 + assertTrue(stringRedisTemplate.getExpire(key) > 0 && stringRedisTemplate.getExpire(key) <= 60);
  78 + assertEquals(3, stringRedisTemplate.opsForHash().size(key));
  79 + }
  80 +
  81 + @Test
  82 + public void testAnotherUserCannotOverwriteConfirmation() {
  83 + dao.create(qrcodeId, "hash");
  84 + dao.confirm(qrcodeId, 1L);
  85 + assertServiceException(() -> dao.confirm(qrcodeId, 2L), AUTH_QRCODE_ALREADY_CONFIRMED);
  86 + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue());
  87 + }
  88 +
  89 + @Test
  90 + public void testWrongSecretDoesNotConsume() {
  91 + dao.create(qrcodeId, "hash");
  92 + assertServiceException(() -> dao.poll(qrcodeId, "wrong"), AUTH_QRCODE_INVALID);
  93 + dao.confirm(qrcodeId, 1L);
  94 + assertServiceException(() -> dao.poll(qrcodeId, "wrong"), AUTH_QRCODE_INVALID);
  95 + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue());
  96 + assertFalse(stringRedisTemplate.hasKey(key));
  97 + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID);
  98 + }
  99 +
  100 + @Test
  101 + public void testMissingKeyIsNotRecreated() {
  102 + assertServiceException(() -> dao.confirm(qrcodeId, 1L), AUTH_QRCODE_INVALID);
  103 + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID);
  104 + assertFalse(stringRedisTemplate.hasKey(key));
  105 + }
  106 +
  107 + @Test
  108 + public void testCreateCannotOverwriteExistingKey() {
  109 + dao.create(qrcodeId, "hash");
  110 + dao.confirm(qrcodeId, 1L);
  111 + assertServiceException(() -> dao.create(qrcodeId, "another"), AUTH_QRCODE_INVALID);
  112 + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue());
  113 + }
  114 +
  115 + @Test
  116 + public void testTenantIsolation() {
  117 + // 准备参数:租户 7 创建二维码。
  118 + TenantContextHolder.setTenantId(7L);
  119 + dao.create(qrcodeId, "hash");
  120 + try {
  121 + // 租户 8 不能确认或领取租户 7 的二维码。
  122 + TenantContextHolder.setTenantId(8L);
  123 + assertServiceException(() -> dao.confirm(qrcodeId, 1L), AUTH_QRCODE_INVALID);
  124 + assertServiceException(() -> dao.poll(qrcodeId, "hash"), AUTH_QRCODE_INVALID);
  125 +
  126 + // 原租户仍可正常确认和领取,其他租户的请求未影响原记录。
  127 + TenantContextHolder.setTenantId(7L);
  128 + dao.confirm(qrcodeId, 1L);
  129 + assertEquals(1L, dao.poll(qrcodeId, "hash").getValue());
  130 + } finally {
  131 + stringRedisTemplate.delete(key + ":7");
  132 + }
  133 + }
  134 +
  135 + @Test
  136 + public void testConcurrentClaimOnlyOneWinner() throws Exception {
  137 + dao.create(qrcodeId, "hash");
  138 + dao.confirm(qrcodeId, 1L);
  139 + ExecutorService executor = Executors.newFixedThreadPool(8);
  140 + CountDownLatch start = new CountDownLatch(1);
  141 + List<Future<Boolean>> futures = new ArrayList<>();
  142 + try {
  143 + for (int i = 0; i < 8; i++) {
  144 + futures.add(executor.submit(() -> {
  145 + start.await();
  146 + try {
  147 + Pair<AuthQrcodeStatusEnum, Long> result = dao.poll(qrcodeId, "hash");
  148 + return result.getKey() == AuthQrcodeStatusEnum.CONFIRMED && result.getValue().equals(1L);
  149 + } catch (ServiceException ex) {
  150 + assertEquals(AUTH_QRCODE_INVALID.getCode(), ex.getCode());
  151 + return false;
  152 + }
  153 + }));
  154 + }
  155 + start.countDown();
  156 + int winners = 0;
  157 + for (Future<Boolean> future : futures) {
  158 + if (future.get(10, TimeUnit.SECONDS)) winners++;
  159 + }
  160 + assertEquals(1, winners);
  161 + assertFalse(stringRedisTemplate.hasKey(key));
  162 + } finally {
  163 + executor.shutdownNow();
  164 + }
  165 + }
  166 +
  167 +}
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthQrcodeServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.system.service.auth;
  2 +
  3 +import cn.hutool.core.lang.Pair;
  4 +import cn.hutool.crypto.digest.DigestUtil;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  6 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodeCreateRespVO;
  7 +import cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthQrcodePollReqVO;
  8 +import cn.iocoder.yudao.module.system.dal.redis.auth.AdminAuthQrcodeRedisDAO;
  9 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
  10 +import jakarta.annotation.Resource;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.context.annotation.Import;
  13 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
  14 +
  15 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
  16 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
  17 +import static org.junit.jupiter.api.Assertions.*;
  18 +import static org.mockito.Mockito.*;
  19 +
  20 +/**
  21 + * {@link AdminAuthQrcodeServiceImpl} 的单元测试类
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +@Import(AdminAuthQrcodeServiceImpl.class)
  26 +public class AdminAuthQrcodeServiceImplTest extends BaseDbUnitTest {
  27 +
  28 + @Resource
  29 + private AdminAuthQrcodeServiceImpl authQrcodeService;
  30 + @MockitoBean
  31 + private AdminAuthQrcodeRedisDAO authQrcodeRedisDAO;
  32 +
  33 + @Test
  34 + public void testCreateAuthQrcode() {
  35 + // 调用
  36 + AuthQrcodeCreateRespVO result = authQrcodeService.createAuthQrcode();
  37 +
  38 + // 校验:二维码不包含领取凭证,Redis 只保存凭证摘要。
  39 + assertTrue(result.getQrcodeId().matches("[a-f0-9]{32}"));
  40 + assertTrue(result.getSecret().matches("[a-f0-9]{32}"));
  41 + assertNotEquals(result.getQrcodeId(), result.getSecret());
  42 + assertFalse(result.getQrContent().contains(result.getSecret()));
  43 + assertEquals("YuDaoLogin:" + result.getQrcodeId(), result.getQrContent());
  44 + assertEquals(180, result.getExpiresIn());
  45 + verify(authQrcodeRedisDAO).create(result.getQrcodeId(), DigestUtil.sha256Hex(result.getSecret()));
  46 + }
  47 +
  48 + @Test
  49 + public void testConfirmAuthQrcode() {
  50 + // 准备参数
  51 + String qrcodeId = randomString();
  52 + Long userId = randomLongId();
  53 +
  54 + // 调用
  55 + authQrcodeService.confirmAuthQrcode(qrcodeId, userId);
  56 +
  57 + // 校验
  58 + verify(authQrcodeRedisDAO).confirm(qrcodeId, userId);
  59 + }
  60 +
  61 + @Test
  62 + public void testPollAuthQrcode() {
  63 + // 准备参数与 mock
  64 + AuthQrcodePollReqVO reqVO = new AuthQrcodePollReqVO().setSecret(randomString());
  65 + reqVO.setQrcodeId(randomString());
  66 + Pair<AuthQrcodeStatusEnum, Long> expected = Pair.of(AuthQrcodeStatusEnum.CONFIRMED, randomLongId());
  67 + when(authQrcodeRedisDAO.poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret())))
  68 + .thenReturn(expected);
  69 +
  70 + // 调用
  71 + Pair<AuthQrcodeStatusEnum, Long> result = authQrcodeService.pollAuthQrcode(reqVO);
  72 +
  73 + // 校验:领取时同样使用摘要,不把原始凭证传入 Redis。
  74 + assertEquals(expected, result);
  75 + verify(authQrcodeRedisDAO).poll(reqVO.getQrcodeId(), DigestUtil.sha256Hex(reqVO.getSecret()));
  76 + }
  77 +
  78 +}
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/auth/AdminAuthServiceImplTest.java
1 1 package cn.iocoder.yudao.module.system.service.auth;
2 2  
  3 +import cn.hutool.core.lang.Pair;
3 4 import cn.hutool.core.util.ReflectUtil;
4 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 6 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
6 7 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
  8 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
7 9 import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
8 10 import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO;
9 11 import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
10 12 import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
11 13 import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
12 14 import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
  15 +import cn.iocoder.yudao.module.system.enums.auth.AuthQrcodeStatusEnum;
13 16 import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
14 17 import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
  18 +import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
15 19 import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
16 20 import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum;
17 21 import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
... ... @@ -24,6 +28,7 @@ import com.anji.captcha.service.CaptchaService;
24 28 import jakarta.annotation.Resource;
25 29 import jakarta.validation.Validation;
26 30 import jakarta.validation.Validator;
  31 +import org.junit.jupiter.api.AfterEach;
27 32 import org.junit.jupiter.api.BeforeEach;
28 33 import org.junit.jupiter.api.Test;
29 34 import org.springframework.context.annotation.Import;
... ... @@ -35,7 +40,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic
35 40 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
36 41 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
37 42 import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
38   -import static org.junit.jupiter.api.Assertions.assertEquals;
  43 +import static org.junit.jupiter.api.Assertions.*;
39 44 import static org.mockito.ArgumentMatchers.eq;
40 45 import static org.mockito.Mockito.*;
41 46  
... ... @@ -48,6 +53,8 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
48 53 @MockitoBean
49 54 private AdminUserService userService;
50 55 @MockitoBean
  56 + private AdminAuthQrcodeService authQrcodeService;
  57 + @MockitoBean
51 58 private CaptchaService captchaService;
52 59 @MockitoBean
53 60 private LoginLogService loginLogService;
... ... @@ -70,6 +77,11 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
70 77 Validation.buildDefaultValidatorFactory().getValidator());
71 78 }
72 79  
  80 + @AfterEach
  81 + public void tearDown() {
  82 + TenantContextHolder.clear();
  83 + }
  84 +
73 85 @Test
74 86 public void testAuthenticate_success() {
75 87 // 准备参数
... ... @@ -368,6 +380,123 @@ public class AdminAuthServiceImplTest extends BaseDbUnitTest {
368 380 }
369 381  
370 382 @Test
  383 + public void testQrcodeLogin_success() {
  384 + // 准备参数与 mock
  385 + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode();
  386 + AdminUserDO user = qrcodeUser();
  387 + when(userService.getUser(1L)).thenReturn(user);
  388 + OAuth2AccessTokenDO token = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L));
  389 + when(oauth2TokenService.createAccessToken(1L, UserTypeEnum.ADMIN.getValue(),
  390 + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null)).thenReturn(token);
  391 +
  392 + // 调用
  393 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO);
  394 +
  395 + // 校验
  396 + assertEquals(AuthQrcodeStatusEnum.CONFIRMED.getStatus(), result.getStatus());
  397 + assertEquals(token.getAccessToken(), result.getLogin().getAccessToken());
  398 + verify(loginLogService).createLoginLog(argThat(log ->
  399 + log.getLogType().equals(LoginLogTypeEnum.LOGIN_QRCODE.getType())
  400 + && log.getUserId().equals(1L)
  401 + && log.getResult().equals(LoginResultEnum.SUCCESS.getResult())));
  402 + }
  403 +
  404 + @Test
  405 + public void testQrcodeLogin_disabled() {
  406 + // 准备参数与 mock
  407 + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode();
  408 + when(userService.getUser(1L)).thenReturn(qrcodeUser().setStatus(CommonStatusEnum.DISABLE.getStatus()));
  409 +
  410 + // 调用并校验:复用普通登录的禁用校验和失败日志。
  411 + assertServiceException(() -> authService.qrcodeLogin(reqVO), AUTH_LOGIN_USER_DISABLED);
  412 + verifyNoInteractions(oauth2TokenService);
  413 + verify(loginLogService).createLoginLog(argThat(log ->
  414 + log.getLogType().equals(LoginLogTypeEnum.LOGIN_QRCODE.getType())
  415 + && log.getResult().equals(LoginResultEnum.USER_DISABLED.getResult())));
  416 + }
  417 +
  418 + @Test
  419 + public void testQrcodeLogin_deleted() {
  420 + // 准备参数与 mock
  421 + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode();
  422 +
  423 + // 调用并校验
  424 + assertServiceException(() -> authService.qrcodeLogin(reqVO), USER_NOT_EXISTS);
  425 + verifyNoInteractions(oauth2TokenService);
  426 + }
  427 +
  428 + @Test
  429 + public void testQrcodeLogin_waiting() {
  430 + // 准备参数与 mock
  431 + AuthQrcodePollReqVO reqVO = qrcodeRequest();
  432 + when(authQrcodeService.pollAuthQrcode(reqVO)).thenReturn(Pair.of(AuthQrcodeStatusEnum.WAITING, null));
  433 +
  434 + // 调用
  435 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO);
  436 +
  437 + // 校验
  438 + assertEquals(AuthQrcodeStatusEnum.WAITING.getStatus(), result.getStatus());
  439 + assertNull(result.getLogin());
  440 + verifyNoInteractions(userService, oauth2TokenService, loginLogService);
  441 + }
  442 +
  443 + @Test
  444 + public void testQrcodeLogin_currentTenant() {
  445 + // 准备参数与 mock:扫码登录沿用请求已确定的租户,不切换租户。
  446 + TenantContextHolder.setTenantId(7L);
  447 + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode();
  448 + when(userService.getUser(1L)).thenReturn(qrcodeUser());
  449 + OAuth2AccessTokenDO token = randomPojo(OAuth2AccessTokenDO.class, o -> o.setUserId(1L));
  450 + when(oauth2TokenService.createAccessToken(1L, UserTypeEnum.ADMIN.getValue(),
  451 + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null)).thenAnswer(invocation -> {
  452 + assertEquals(7L, TenantContextHolder.getTenantId());
  453 + assertFalse(TenantContextHolder.isIgnore());
  454 + return token;
  455 + });
  456 +
  457 + // 调用
  458 + AuthQrcodePollRespVO result = authService.qrcodeLogin(reqVO);
  459 +
  460 + // 校验
  461 + assertEquals(token.getAccessToken(), result.getLogin().getAccessToken());
  462 + assertEquals(7L, TenantContextHolder.getTenantId());
  463 + assertFalse(TenantContextHolder.isIgnore());
  464 + }
  465 +
  466 + @Test
  467 + public void testQrcodeLogin_issueFailure() {
  468 + // 准备参数与 mock
  469 + AuthQrcodePollReqVO reqVO = mockConfirmedQrcode();
  470 + when(userService.getUser(1L)).thenReturn(qrcodeUser());
  471 + when(oauth2TokenService.createAccessToken(any(), any(), any(), any()))
  472 + .thenThrow(new IllegalStateException("issue failed"));
  473 +
  474 + // 调用并校验:失败不重领二维码或重签令牌。
  475 + assertThrows(IllegalStateException.class, () -> authService.qrcodeLogin(reqVO));
  476 + verify(authQrcodeService, times(1)).pollAuthQrcode(reqVO);
  477 + verify(oauth2TokenService, times(1)).createAccessToken(any(), any(), any(), any());
  478 + }
  479 +
  480 + private AuthQrcodePollReqVO mockConfirmedQrcode() {
  481 + AuthQrcodePollReqVO reqVO = qrcodeRequest();
  482 + when(authQrcodeService.pollAuthQrcode(reqVO)).thenReturn(Pair.of(AuthQrcodeStatusEnum.CONFIRMED, 1L));
  483 + return reqVO;
  484 + }
  485 +
  486 + private static AuthQrcodePollReqVO qrcodeRequest() {
  487 + AuthQrcodePollReqVO reqVO = new AuthQrcodePollReqVO().setSecret(randomString());
  488 + reqVO.setQrcodeId(randomString());
  489 + return reqVO;
  490 + }
  491 +
  492 + private static AdminUserDO qrcodeUser() {
  493 + AdminUserDO user = new AdminUserDO().setId(1L).setUsername("qrcode-user")
  494 + .setStatus(CommonStatusEnum.ENABLE.getStatus());
  495 + user.setTenantId(7L);
  496 + return user;
  497 + }
  498 +
  499 + @Test
371 500 public void testRefreshToken() {
372 501 // 准备参数
373 502 String refreshToken = randomString();
... ...