feat(auth): 新增 App 扫码登录 PC 端
- 新增 auth/qrcode 模块:生成票据、轮询状态、换取会话令牌 - SecurityUser 增加 avatar 字段,UserSocial 支持按用户 ID 查认证信息 - 新增 QR_CODE_LOGIN Redis 票据与 A025x 扫码异常码 - security 白名单放行 /api/v1/auth/qr-code/*
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package com.youlai.boot.auth.qrcode.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
|
||||
import com.youlai.boot.common.annotation.RateLimit;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.util.SecurityUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 扫码登录接口。
|
||||
* <p>
|
||||
* generate/status/login 不需要登录态(PC 端未登录),由 Security 配置放行;
|
||||
* scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。
|
||||
*/
|
||||
@Tag(name = "02.扫码登录")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth/qr-code")
|
||||
@RequiredArgsConstructor
|
||||
public class QrCodeLoginController {
|
||||
|
||||
private final QrCodeLoginService qrCodeLoginService;
|
||||
|
||||
@Operation(summary = "生成扫码登录票据")
|
||||
@PostMapping("/generate")
|
||||
@RateLimit(limit = 30, window = 60)
|
||||
public Result<QrCodeGenerateVO> generate(HttpServletRequest request) {
|
||||
return Result.success(qrCodeLoginService.generate(getClientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询扫码状态")
|
||||
@GetMapping("/status")
|
||||
@RateLimit(limit = 60, window = 60)
|
||||
public Result<QrCodeStatusVO> status(@RequestParam String ticket) {
|
||||
return Result.success(qrCodeLoginService.status(ticket));
|
||||
}
|
||||
|
||||
@Operation(summary = "APP 标记已扫码")
|
||||
@PostMapping("/scan")
|
||||
public Result<QrCodeStatusVO> scan(@RequestBody @Valid QrCodeTicketForm form) {
|
||||
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "APP 确认登录")
|
||||
@PostMapping("/confirm")
|
||||
public Result<QrCodeStatusVO> confirm(@RequestBody @Valid QrCodeTicketForm form) {
|
||||
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "APP 取消登录")
|
||||
@PostMapping("/cancel")
|
||||
public Result<QrCodeStatusVO> cancel(@RequestBody @Valid QrCodeTicketForm form) {
|
||||
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
|
||||
}
|
||||
|
||||
@Operation(summary = "PC 端换取会话令牌")
|
||||
@PostMapping("/login")
|
||||
public Result<AuthenticationToken> login(@RequestBody @Valid QrCodeTicketForm form) {
|
||||
return Result.success(qrCodeLoginService.login(form.getTicket()));
|
||||
}
|
||||
|
||||
/** 从请求头或连接信息中提取客户端 IP,兼容反向代理 */
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
|
||||
int comma = ip.indexOf(',');
|
||||
return comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
|
||||
}
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
|
||||
return ip.trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.youlai.boot.auth.qrcode.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 扫码登录票据上下文,序列化为 JSON 存入 Redis。
|
||||
* <p>
|
||||
* 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
|
||||
*/
|
||||
@Data
|
||||
public class QrCodeLoginContext {
|
||||
|
||||
/** 票据,UUID 无连字符 */
|
||||
private String ticket;
|
||||
|
||||
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
|
||||
private String status;
|
||||
|
||||
/** 扫码用户 ID,scan 时写入 */
|
||||
private Long userId;
|
||||
|
||||
/** 用户原始昵称(未脱敏),status 接口返回时脱敏 */
|
||||
private String nickname;
|
||||
|
||||
/** 用户头像 URL */
|
||||
private String avatar;
|
||||
|
||||
/** 创建时间戳(毫秒) */
|
||||
private Long createdAt;
|
||||
|
||||
/** 扫码时间戳(毫秒) */
|
||||
private Long scannedAt;
|
||||
|
||||
/** 确认时间戳(毫秒) */
|
||||
private Long confirmedAt;
|
||||
|
||||
/** generate 时的 PC 端 IP,用于审计 */
|
||||
private String clientIp;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.youlai.boot.auth.qrcode.model;
|
||||
|
||||
/**
|
||||
* 扫码登录票据状态。
|
||||
* <p>
|
||||
* WAITING 票据已创建,等待 APP 扫码
|
||||
* SCANNED APP 已扫码,等待用户在手机上确认
|
||||
* CONFIRMED 用户已在 APP 上确认登录
|
||||
* LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
|
||||
* CANCELED 用户在 APP 上取消登录
|
||||
* EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
|
||||
*/
|
||||
public enum QrCodeLoginStatusEnum {
|
||||
|
||||
WAITING,
|
||||
SCANNED,
|
||||
CONFIRMED,
|
||||
LOGGED_IN,
|
||||
CANCELED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.youlai.boot.auth.qrcode.model.form;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
|
||||
*/
|
||||
@Data
|
||||
public class QrCodeTicketForm {
|
||||
|
||||
@NotBlank(message = "票据不能为空")
|
||||
private String ticket;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* generate 接口响应。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码票据生成结果")
|
||||
public class QrCodeGenerateVO {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "有效期(秒)")
|
||||
private Integer expireSeconds;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* status/scan/confirm/cancel 接口响应。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码登录状态")
|
||||
public class QrCodeStatusVO {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "脱敏昵称,SCANNED 之后返回")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "头像 URL,SCANNED 之后返回")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "剩余有效期(秒)")
|
||||
private Integer expireSeconds;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.youlai.boot.auth.qrcode.service;
|
||||
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
|
||||
/**
|
||||
* 扫码登录服务。
|
||||
*/
|
||||
public interface QrCodeLoginService {
|
||||
|
||||
/** 生成票据,未登录调用 */
|
||||
QrCodeGenerateVO generate(String clientIp);
|
||||
|
||||
/** 查询状态,未登录调用 */
|
||||
QrCodeStatusVO status(String ticket);
|
||||
|
||||
/** APP 标记已扫码,需 APP 端已登录 */
|
||||
QrCodeStatusVO scan(String ticket, Long userId);
|
||||
|
||||
/** APP 确认登录,需 APP 端已登录 */
|
||||
QrCodeStatusVO confirm(String ticket, Long userId);
|
||||
|
||||
/** APP 取消登录,需 APP 端已登录 */
|
||||
QrCodeStatusVO cancel(String ticket, Long userId);
|
||||
|
||||
/** PC 端用票据换取会话令牌,未登录调用 */
|
||||
AuthenticationToken login(String ticket);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.youlai.boot.auth.qrcode.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
|
||||
import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import com.youlai.boot.framework.security.token.TokenManager;
|
||||
import com.youlai.boot.system.service.UserSocialService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 扫码登录服务实现。
|
||||
* <p>
|
||||
* 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
|
||||
* {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常,
|
||||
* 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。
|
||||
* PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)},
|
||||
* 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
|
||||
/** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
|
||||
private static final int DEFAULT_EXPIRE_SECONDS = 300;
|
||||
|
||||
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
|
||||
private static final int MIN_REMAIN_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储),
|
||||
* 用它把 Map 转回强类型上下文对象。JsonMapper 线程安全,复用单例。
|
||||
*/
|
||||
private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final TokenManager tokenManager;
|
||||
private final UserSocialService userSocialService;
|
||||
|
||||
@Override
|
||||
public QrCodeGenerateVO generate(String clientIp) {
|
||||
String ticket = IdUtil.fastSimpleUUID();
|
||||
QrCodeLoginContext ctx = new QrCodeLoginContext();
|
||||
ctx.setTicket(ticket);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name());
|
||||
ctx.setCreatedAt(System.currentTimeMillis());
|
||||
ctx.setClientIp(clientIp);
|
||||
save(ctx, DEFAULT_EXPIRE_SECONDS);
|
||||
return QrCodeGenerateVO.builder()
|
||||
.ticket(ticket)
|
||||
.expireSeconds(DEFAULT_EXPIRE_SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO status(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO scan(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
|
||||
fillUserInfo(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
|
||||
ctx.setScannedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO confirm(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
|
||||
requireSameUser(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
|
||||
ctx.setConfirmedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO cancel(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
// 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝
|
||||
if (current != QrCodeLoginStatusEnum.WAITING
|
||||
&& current != QrCodeLoginStatusEnum.SCANNED
|
||||
&& current != QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
// 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消
|
||||
if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
|
||||
requireSameUser(ctx, userId);
|
||||
}
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationToken login(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
|
||||
SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId());
|
||||
if (securityUser == null) {
|
||||
throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
|
||||
}
|
||||
SecurityUserDetails details = new SecurityUserDetails(securityUser);
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
details, null, details.getAuthorities());
|
||||
AuthenticationToken token = tokenManager.generateToken(authentication);
|
||||
// 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
|
||||
save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ======================== private ========================
|
||||
|
||||
/** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
|
||||
private QrCodeLoginContext loadOrThrow(String ticket) {
|
||||
if (StrUtil.isBlank(ticket)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
Object raw = redisTemplate.opsForValue().get(key(ticket));
|
||||
if (raw == null) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
if (raw instanceof QrCodeLoginContext ctx) {
|
||||
return ctx;
|
||||
}
|
||||
// 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型
|
||||
try {
|
||||
return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class);
|
||||
} catch (Exception e) {
|
||||
log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e);
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
|
||||
private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
|
||||
if (!expected.name().equals(ctx.getStatus())) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
|
||||
private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
|
||||
if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
|
||||
private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
|
||||
SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
|
||||
if (info == null) {
|
||||
throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
|
||||
}
|
||||
ctx.setUserId(userId);
|
||||
ctx.setNickname(info.getNickname());
|
||||
ctx.setAvatar(info.getAvatar());
|
||||
}
|
||||
|
||||
/** 重写整个上下文并刷新 TTL */
|
||||
private void save(QrCodeLoginContext ctx, int ttl) {
|
||||
redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
|
||||
private int remainingSeconds(String ticket) {
|
||||
Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
|
||||
return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
|
||||
}
|
||||
|
||||
/** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */
|
||||
private int refreshTtl(String ticket) {
|
||||
int remain = remainingSeconds(ticket);
|
||||
return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
|
||||
}
|
||||
|
||||
/** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */
|
||||
private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
|
||||
QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
String nickname = null;
|
||||
String avatar = null;
|
||||
// WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像
|
||||
if (status == QrCodeLoginStatusEnum.SCANNED
|
||||
|| status == QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
|
||||
avatar = ctx.getAvatar();
|
||||
}
|
||||
return QrCodeStatusVO.builder()
|
||||
.ticket(ctx.getTicket())
|
||||
.status(status.name())
|
||||
.nickname(nickname)
|
||||
.avatar(avatar)
|
||||
.expireSeconds(expireSeconds)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 拼接票据在 Redis 中的完整 Key */
|
||||
private String key(String ticket) {
|
||||
return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.youlai.boot.auth.qrcode.util;
|
||||
|
||||
/**
|
||||
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
|
||||
* <p>
|
||||
* 长度 1:原样返回
|
||||
* 长度 2:首字 + *
|
||||
* 长度 ≥3:首字 + (n-2) 个 * + 末字
|
||||
*/
|
||||
public final class QrCodeNicknameMasker {
|
||||
|
||||
private QrCodeNicknameMasker() {
|
||||
}
|
||||
|
||||
public static String mask(String nickname) {
|
||||
if (nickname == null || nickname.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int len = nickname.length();
|
||||
if (len == 1) {
|
||||
return nickname;
|
||||
}
|
||||
if (len == 2) {
|
||||
return nickname.charAt(0) + "*";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(nickname.charAt(0));
|
||||
for (int i = 0; i < len - 2; i++) {
|
||||
sb.append('*');
|
||||
}
|
||||
sb.append(nickname.charAt(len - 1));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public interface RedisConstants {
|
||||
String REVOKED_JTI = BLACKLIST_TOKEN;
|
||||
// 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效
|
||||
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
|
||||
// 扫码登录票据上下文(ticket -> QrCodeLoginContext)
|
||||
String QR_CODE_LOGIN = "auth:qr_code:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,12 @@ public enum ResultCode implements IResultCode, Serializable {
|
||||
USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"),
|
||||
USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"),
|
||||
|
||||
/** A025x:扫码登录异常 */
|
||||
QR_CODE_NOT_FOUND("A0250", "扫码登录票据不存在或已过期"),
|
||||
QR_CODE_STATUS_ILLEGAL("A0251", "当前扫码状态不允许该操作"),
|
||||
QR_CODE_USER_MISMATCH("A0252", "操作用户与扫码用户不一致"),
|
||||
QR_CODE_ALREADY_USED("A0253", "扫码登录票据已被使用"),
|
||||
|
||||
/** A03xx:访问权限异常 */
|
||||
ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"),
|
||||
ACCESS_UNAUTHORIZED("A0301", "访问未授权"),
|
||||
|
||||
@@ -47,6 +47,11 @@ public class SecurityUser {
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 头像 URL
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 角色编码集合
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,14 @@ public interface UserSocialService extends IService<UserSocial> {
|
||||
*/
|
||||
SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
|
||||
|
||||
/**
|
||||
* 根据用户ID获取用户认证信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 用户认证信息
|
||||
*/
|
||||
SecurityUser getAuthInfoByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 更新session_key
|
||||
*
|
||||
|
||||
@@ -94,6 +94,11 @@ public class UserSocialServiceImpl extends ServiceImpl<UserSocialMapper, UserSoc
|
||||
return baseMapper.getAuthInfoByUserId(userSocial.getUserId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityUser getAuthInfoByUserId(Long userId) {
|
||||
return baseMapper.getAuthInfoByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSessionKey(Long id, String sessionKey) {
|
||||
UserSocial userSocial = getById(id);
|
||||
|
||||
@@ -95,6 +95,9 @@ security:
|
||||
- /api/v1/auth/refresh-token # 刷新令牌接口
|
||||
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
|
||||
- /api/v1/logs/** # 日志接口(访问日志列表)
|
||||
- /api/v1/auth/qr-code/generate # 扫码登录-生成票据
|
||||
- /api/v1/auth/qr-code/status # 扫码登录-查询状态
|
||||
- /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
|
||||
# 非安全端点路径,完全绕过 Spring Security 的过滤器
|
||||
unsecured-urls:
|
||||
- ${springdoc.swagger-ui.path}
|
||||
|
||||
@@ -94,6 +94,9 @@ security:
|
||||
- /api/v1/auth/refresh-token # 刷新令牌接口
|
||||
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
|
||||
- /api/v1/logs/** # 日志接口(访问日志列表)
|
||||
- /api/v1/auth/qr-code/generate # 扫码登录-生成票据
|
||||
- /api/v1/auth/qr-code/status # 扫码登录-查询状态
|
||||
- /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌
|
||||
# 非安全端点路径,完全绕过 Spring Security 的过滤器
|
||||
unsecured-urls:
|
||||
- ${springdoc.swagger-ui.path}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<result property="deptId" column="dept_id" jdbcType="BIGINT"/>
|
||||
<result property="password" column="password" jdbcType="VARCHAR"/>
|
||||
<result property="status" column="status" jdbcType="BOOLEAN"/>
|
||||
<result property="avatar" column="avatar" jdbcType="VARCHAR"/>
|
||||
<collection property="roles" ofType="string" javaType="java.util.Set">
|
||||
<result column="code"/>
|
||||
</collection>
|
||||
@@ -26,6 +27,7 @@
|
||||
u.dept_id,
|
||||
u.password,
|
||||
u.status,
|
||||
u.avatar AS avatar,
|
||||
r.code
|
||||
FROM
|
||||
sys_user u
|
||||
|
||||
Reference in New Issue
Block a user