Merge branch 'master' of gitee.com:youlaiorg/youlai-boot

# Conflicts:
#	src/main/java/com/youlai/boot/auth/security/config/SecurityConfig.java
#	src/main/resources/application-dev.yml
#	src/main/resources/application-prod.yml
This commit is contained in:
TongTongStudio
2026-07-30 17:49:40 +08:00
159 changed files with 1979 additions and 1359 deletions

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.app.controller;
import com.youlai.boot.app.model.req.MobileLoginReq;
import com.youlai.boot.app.model.req.MobileRegisterReq;
import com.youlai.boot.app.service.AppAuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;

View File

@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.query.UserQuery;
import com.youlai.boot.system.model.vo.UserExportVO;
import com.youlai.boot.system.model.vo.UserPageVO;
@@ -46,9 +46,9 @@ public interface AppUserMapper extends BaseMapper<AppUser> {
* @param username 用户名
* @return 认证信息
*/
UserAuthInfo getAuthInfoByUsername(String username);
SecurityUser getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
default SecurityUser getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
@@ -58,9 +58,9 @@ public interface AppUserMapper extends BaseMapper<AppUser> {
* @param mobile 手机号
* @return 认证信息
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
SecurityUser getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
default SecurityUser getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.app.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.app.model.entity.AppUser;
import com.youlai.boot.app.model.form.AppUserForm;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.model.form.*;
import com.youlai.boot.system.model.query.UserQuery;
@@ -71,11 +71,11 @@ public interface AppUserService extends IService<AppUser> {
* 根据用户名获取认证信息
*
* @param username 用户名
* @return {@link UserAuthInfo}
* @return {@link SecurityUser}
*/
UserAuthInfo getAuthInfoByUsername(String username);
SecurityUser getAuthInfoByUsername(String username);
default UserAuthInfo getAuthCredentialsByUsername(String username) {
default SecurityUser getAuthCredentialsByUsername(String username) {
return getAuthInfoByUsername(username);
}
@@ -187,11 +187,11 @@ public interface AppUserService extends IService<AppUser> {
* 根据手机号获取用户认证信息
*
* @param mobile 手机号
* @return {@link UserAuthInfo}
* @return {@link SecurityUser}
*/
UserAuthInfo getAuthInfoByMobile(String mobile);
SecurityUser getAuthInfoByMobile(String mobile);
default UserAuthInfo getAuthCredentialsByMobile(String mobile) {
default SecurityUser getAuthCredentialsByMobile(String mobile) {
return getAuthInfoByMobile(mobile);
}

View File

@@ -21,7 +21,7 @@ import com.youlai.boot.framework.integration.mail.service.MailService;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.converter.UserConverter;
@@ -208,11 +208,11 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
* 根据用户名获取认证凭证信息
*
* @param username 用户名
* @return 用户认证凭证信息 {@link UserAuthInfo}
* @return 用户认证凭证信息 {@link SecurityUser}
*/
@Override
public UserAuthInfo getAuthInfoByUsername(String username) {
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
public SecurityUser getAuthInfoByUsername(String username) {
SecurityUser userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)
@@ -229,11 +229,11 @@ public class AppUserServiceImpl extends ServiceImpl<AppUserMapper, AppUser> impl
* @return 用户认证信息
*/
@Override
public UserAuthInfo getAuthInfoByMobile(String mobile) {
public SecurityUser getAuthInfoByMobile(String mobile) {
if (StrUtil.isBlank(mobile)) {
return null;
}
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
SecurityUser userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles();
// 获取数据权限列表(用于并集策略)

View File

@@ -34,7 +34,7 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor
public class LocalAppFileService implements AppFileService {
@Value("${oss.local.storage-path}")
@Value("${file-storage.local.path}")
private String storagePath;
/**

View File

@@ -5,8 +5,8 @@ import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;

View File

@@ -4,8 +4,8 @@ import com.youlai.boot.auth.model.form.WxMaBindMobileForm;
import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;

View File

@@ -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.framework.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();
}
}

View File

@@ -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;
/** 扫码用户 IDscan 时写入 */
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;
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 = "头像 URLSCANNED 之后返回")
private String avatar;
@Schema(description = "剩余有效期(秒)")
private Integer expireSeconds;
}

View File

@@ -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);
}

View File

@@ -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 形式存入 RedisTTL 与有效期一致;状态流转的合法性全部由
* {@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);
}
}

View File

@@ -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();
}
}

View File

@@ -1,18 +1,20 @@
package com.youlai.boot.framework.security.config;
package com.youlai.boot.auth.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.handler.MyAccessDeniedHandler;
import com.youlai.boot.framework.security.handler.MyAuthenticationEntryPoint;
import com.youlai.boot.framework.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.framework.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.framework.security.service.SysUserDetailsService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.auth.security.filter.CaptchaValidationFilter;
import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler;
import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint;
import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -30,9 +32,13 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
/**
* Spring Security 配置类
* Spring Security 配置类
* <p>
* 归使用方auth 模块安全规则放行路径CORSProvider 装配响应格式
* 因项目而异不应由框架层强制装配
*
* @author Ray.Hao
* @since 4.3.1
@@ -45,22 +51,15 @@ public class SecurityConfig {
private final RedisTemplate<String, Object> redisTemplate;
private final PasswordEncoder passwordEncoder;
private final TokenManager tokenManager;
private final UserService userService;
private final SysUserDetailsService userDetailsService;
private final SecurityUserDetailsService userDetailsService;
private final CaptchaService captchaService;
private final SecurityProperties securityProperties;
/**
* 配置安全过滤链 SecurityFilterChain
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requestMatcherRegistry -> {
// 配置无需登录即可访问的公开接口配置文件方式
String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
@@ -75,33 +74,25 @@ public class SecurityConfig {
)
.exceptionHandling(configurer ->
configurer
.authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 未认证异常处理器
.accessDeniedHandler(new MyAccessDeniedHandler()) // 无权限访问异常处理器
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler())
)
// 禁用默认的 Spring Security 特性适用于前后端分离架构
.sessionManagement(configurer ->
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态认证不使用 Session
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF 防护前后端分离无需此防护机制
.formLogin(AbstractHttpConfigurer::disable) // 禁用默认的表单登录功能前后端分离采用 Token 认证方式
.httpBasic(AbstractHttpConfigurer::disable) // 禁用 HTTP Basic 认证避免弹窗式登录
// 禁用 X-Frame-Options 响应头允许页面被嵌套到 iframe
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
// 验证码校验过滤器
// 验证码校验使用方过滤器直接写 JSON 响应
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
// Token 认证Starter 过滤器 AuthenticationException 交给 ExceptionTranslationFilter 处理
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class)
// 移动设备API签名验证过滤器仅对 /api/v1/sn/** 路径生效
.addFilterBefore(new MobileApiSignatureFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
.build();
}
/**
* 配置Web安全自定义器以忽略特定请求路径的安全性检查
* <p>
* 该配置用于指定哪些请求路径不经过Spring Security过滤器链通常用于静态资源文件
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> {
@@ -112,38 +103,27 @@ public class SecurityConfig {
};
}
/**
* 默认密码认证的 Provider
*/
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
return daoAuthenticationProvider;
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
/**
* 短信验证码认证 Provider
*/
@Bean
public SmsAuthenticationProvider smsAuthenticationProvider() {
return new SmsAuthenticationProvider(userService, redisTemplate);
public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) {
return new SmsAuthenticationProvider(userAuthPort, redisTemplate);
}
/**
* 微信小程序认证 Provider
*/
@Bean
public WxMaAuthenticationProvider wechatMiniAuthenticationProvider(
WxMaService wxMaService,
SysUserDetailsService sysUserDetailsService
UserAuthenticationPort userAuthenticationPort,
UserSocialService userSocialService
) {
return new WxMaAuthenticationProvider(wxMaService, sysUserDetailsService);
return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService);
}
/**
* 认证管理器
*/
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoAuthenticationProvider,
@@ -156,5 +136,4 @@ public class SecurityConfig {
wxMaAuthenticationProvider
);
}
}

View File

@@ -1,17 +1,16 @@
package com.youlai.boot.framework.security.exception;
package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 需要绑定手机号异常
* 需要绑定手机号异常微信小程序登录未绑定手机号时抛出
*/
public class NeedBindMobileException extends AuthenticationException {
public class MobileNotBoundException extends AuthenticationException {
private final String openid;
private final String sessionKey;
public NeedBindMobileException(String openid, String sessionKey) {
public MobileNotBoundException(String openid, String sessionKey) {
super("需要绑定手机号");
this.openid = openid;
this.sessionKey = sessionKey;
@@ -24,5 +23,4 @@ public class NeedBindMobileException extends AuthenticationException {
public String getSessionKey() {
return sessionKey;
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.exception;
package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
@@ -9,6 +9,7 @@ import org.springframework.security.core.AuthenticationException;
* @since 2025/3/1
*/
public class SmsCaptchaException extends AuthenticationException {
public SmsCaptchaException(String msg) {
super(msg);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.filter;
package com.youlai.boot.auth.security.filter;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
@@ -29,7 +29,9 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 图形验证码校验过滤器
* 图形验证码校验过滤器
* <p>
* 归使用方因为验证码规则哪些接口需要验证码验证码类型因项目而异
*/
public class CaptchaValidationFilter extends OncePerRequestFilter {
@@ -49,20 +51,17 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 非登录接口直接放行
if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
chain.doFilter(request, response);
return;
}
// 仅支持 JSON 登录
String contentType = request.getContentType();
if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
ResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
// 包装请求确保下游还能读取 body
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request, -1);
byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream());
@@ -85,9 +84,6 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
/**
* Simple wrapper to allow repeated reads of the request body after we've parsed it here.
*/
private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] cachedBody;
@@ -139,5 +135,3 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
}
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.handler;
package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -9,17 +9,18 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* 无权限访问处理器
* 无权限访问处理器
* <p>
* 归使用方因为 JSON 响应格式因项目而异
*
* @author Ray.Hao
* @since 2.0.0
*/
public class MyAccessDeniedHandler implements AccessDeniedHandler {
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
// 权限不足返回 403 Forbidden
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_PERMISSION_EXCEPTION);
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.handler;
package com.youlai.boot.auth.security.handler;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
@@ -14,35 +14,24 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 统一处理 Spring Security 认证失败响应
* 统一处理 Spring Security 认证失败响应
* <p>
* 归使用方因为 JSON 响应格式Result 结构错误码因项目而异
*
* @author Ray.Hao
* @since 2.0.0
*/
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 认证失败处理入口方法
*
* @param request 触发异常的请求对象可用于获取请求头参数等
* @param response 响应对象用于写入错误信息
* @param authException 认证异常对象包含具体失败原因
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (authException instanceof BadCredentialsException) {
// 用户名或密码错误
ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR);
} else if(authException instanceof InsufficientAuthenticationException){
// 请求头缺失AuthorizationToken格式错误Token过期签名验证失败
} else if (authException instanceof InsufficientAuthenticationException) {
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
} else {
// 其他未明确处理的认证异常如账户被锁定账户禁用等
ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
}
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.model;
package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,13 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
* 短信验证码认证 Token
* 短信验证码认证 Token
* <p>
* 用于短信验证码登录场景遵循 Spring Security 认证模型
* <ul>
* <li>未认证状态principal 为手机号credentials 为验证码</li>
* <li>已认证状态principal 为用户详情credentials null</li>
* </ul>
* 未认证principal=手机号credentials=验证码已认证principal=SecurityUserDetailscredentials=null
*
* @author Ray.Hao
* @since 2.20.0
@@ -24,30 +20,9 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 621L;
/**
* 认证信息
* <ul>
* <li>未认证时手机号</li>
* <li>已认证时SysUserDetails 用户详情</li>
* </ul>
*/
private final Object principal;
/**
* 凭证信息
* <ul>
* <li>未认证时短信验证码</li>
* <li>已认证时null</li>
* </ul>
*/
private final Object credentials;
/**
* 创建未认证的 Token
*
* @param mobile 手机号
* @param verifyCode 短信验证码
*/
public SmsAuthenticationToken(String mobile, String verifyCode) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = mobile;
@@ -55,12 +30,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
/**
* 创建已认证的 Token
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
*/
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -68,13 +37,6 @@ public class SmsAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
/**
* 创建已认证的 Token静态工厂方法
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
* @return 已认证的 SmsAuthenticationToken
*/
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new SmsAuthenticationToken(principal, authorities);
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.framework.security.model;
package com.youlai.boot.auth.security.model;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@@ -8,7 +8,9 @@ import java.io.Serial;
import java.util.Collection;
/**
* 微信小程序认证 Token
* 微信小程序认证 Token
* <p>
* 未认证principal=微信code已认证principal=SecurityUserDetails
*
* @author Ray.Hao
* @since 4.0.0
@@ -18,25 +20,9 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
@Serial
private static final long serialVersionUID = 622L;
/**
* 认证信息
* 未认证时微信code
* 已认证时SysUserDetails 用户详情
*/
private final Object principal;
/**
* 凭证信息
* 未认证时null
* 已认证时null
*/
private final Object credentials;
/**
* 创建未认证的 Token
*
* @param code 微信小程序code
*/
public WxMaAuthenticationToken(String code) {
super(AuthorityUtils.NO_AUTHORITIES);
this.principal = code;
@@ -44,12 +30,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
setAuthenticated(false);
}
/**
* 创建已认证的 Token
*
* @param principal 用户详情SysUserDetails
* @param authorities 授权信息
*/
public WxMaAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
@@ -57,9 +37,6 @@ public class WxMaAuthenticationToken extends AbstractAuthenticationToken {
super.setAuthenticated(true);
}
/**
* 创建已认证的 Token静态工厂方法
*/
public static WxMaAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new WxMaAuthenticationToken(principal, authorities);
}

View File

@@ -1,13 +1,13 @@
package com.youlai.boot.framework.security.provider;
package com.youlai.boot.auth.security.provider;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.SmsCaptchaException;
import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;
@@ -19,47 +19,34 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 短信验证码认证 Provider
* <p>
* 实现 Spring Security {@link AuthenticationProvider} 接口处理短信验证码登录认证
* <p>
* 认证流程
* <ol>
* <li>根据手机号查询用户信息</li>
* <li>校验用户状态是否禁用</li>
* <li>校验用户状态</li>
* <li>校验短信验证码 Redis 缓存比对</li>
* <li>验证成功后删除验证码防止重复使用</li>
* <li>验证成功后删除验证码</li>
* <li>返回已认证的 Authentication</li>
* </ol>
*
* @author Ray.Hao
* @since 2.17.0
* @see SmsAuthenticationToken
* @see AuthenticationProvider
*/
@Slf4j
public class SmsAuthenticationProvider implements AuthenticationProvider {
private final UserService userService;
private final UserAuthenticationPort userAuthPort;
private final RedisTemplate<String, Object> redisTemplate;
public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) {
this.userService = userService;
public SmsAuthenticationProvider(UserAuthenticationPort userAuthPort, RedisTemplate<String, Object> redisTemplate) {
this.userAuthPort = userAuthPort;
this.redisTemplate = redisTemplate;
}
/**
* 执行短信验证码认证
*
* @param authentication 未认证的 {@link SmsAuthenticationToken}
* @return 已认证的 {@link SmsAuthenticationToken}
* @throws AuthenticationException 认证失败异常
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String mobile = (String) authentication.getPrincipal();
String inputVerifyCode = (String) authentication.getCredentials();
// 参数校验
if (StrUtil.isBlank(mobile)) {
log.warn("短信验证码登录失败:手机号为空");
throw new SmsCaptchaException("手机号不能为空");
@@ -69,21 +56,18 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码不能为空");
}
// 根据手机号获取用户信息
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
SecurityUser securityUser = userAuthPort.getAuthInfoByMobile(mobile);
if (userAuthInfo == null) {
if (securityUser == null) {
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
throw new UsernameNotFoundException("用户不存在");
}
// 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
// 校验短信验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
@@ -97,24 +81,13 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
throw new SmsCaptchaException("验证码错误");
}
// 验证成功后删除验证码防止重复使用
redisTemplate.delete(cacheKey);
// 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
// 创建已认证的 SmsAuthenticationToken
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
log.info("短信验证码登录成功:用户名={},手机号={}", securityUser.getUsername(), mobile);
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
}
/**
* 支持的认证类型
*
* @param authentication 认证类型
* @return 是否支持该认证类型
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);

View File

@@ -1,14 +1,16 @@
package com.youlai.boot.framework.security.provider;
package com.youlai.boot.auth.security.provider;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.hutool.core.util.ObjectUtil;
import com.youlai.boot.framework.security.exception.NeedBindMobileException;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
import com.youlai.boot.framework.security.service.SysUserDetailsService;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
@@ -26,7 +28,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
public class WxMaAuthenticationProvider implements AuthenticationProvider {
private final WxMaService wxMaService;
private final SysUserDetailsService sysUserDetailsService;
private final UserAuthenticationPort userAuthPort;
private final UserSocialService userSocialService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
@@ -38,44 +41,35 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
}
try {
// 1. code 换取 openid
WxMaJscode2SessionResult session = wxMaService.jsCode2SessionInfo(code);
String openid = session.getOpenid();
String sessionKey = session.getSessionKey();
log.info("微信小程序登录openid={}", openid);
// 2. 根据 openid 查询绑定信息
UserSocial userSocial = sysUserDetailsService.getWechatMiniBindInfo(openid);
UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
if (userSocial == null) {
// 未绑定抛出异常提示需要绑定手机号
log.info("微信小程序登录用户未绑定手机号openid={}", openid);
throw new NeedBindMobileException(openid, sessionKey);
throw new MobileNotBoundException(openid, sessionKey);
}
// 3. 获取用户认证信息
UserAuthInfo userAuthInfo = sysUserDetailsService.getAuthInfoByWechatOpenid(openid);
SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
if (userAuthInfo == null) {
if (securityUser == null) {
log.warn("微信小程序登录失败用户不存在openid={}", openid);
throw new UsernameNotFoundException("用户不存在");
}
// 4. 检查用户状态
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("微信小程序登录失败用户已禁用username={}", userAuthInfo.getUsername());
if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) {
log.warn("微信小程序登录失败用户已禁用username={}", securityUser.getUsername());
throw new DisabledException("用户已被禁用");
}
// 5. 更新 session_key
sysUserDetailsService.updateWechatSessionKey(userSocial.getId(), sessionKey);
// 6. 构建已认证 Token
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("微信小程序登录成功username={}, openid={}", userAuthInfo.getUsername(), openid);
userSocialService.updateSessionKey(userSocial.getId(), sessionKey);
SecurityUserDetails userDetails = new SecurityUserDetails(securityUser);
log.info("微信小程序登录成功username={}, openid={}", securityUser.getUsername(), openid);
return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
} catch (WxErrorException e) {
@@ -88,5 +82,4 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider {
public boolean supports(Class<?> authentication) {
return WxMaAuthenticationToken.class.isAssignableFrom(authentication);
}
}

View File

@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
@@ -58,9 +58,9 @@ public class AuthServiceImpl implements AuthService {
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现)
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SysUserDetailsauthorities 为角色/权限集合)。
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)

View File

@@ -8,12 +8,12 @@ import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.exception.NeedBindMobileException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.WxMaAuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
@@ -69,7 +69,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (NeedBindMobileException e) {
} catch (MobileNotBoundException e) {
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
@@ -234,7 +234,7 @@ public class WxMaAuthServiceImpl implements WxMaAuthService {
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SysUserDetails userDetails = new SysUserDetails(userService.getAuthInfoByMobile(mobile));
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);

View File

@@ -11,7 +11,7 @@ import com.youlai.boot.codegen.model.form.GenConfigForm;
import com.youlai.boot.codegen.model.query.TableQuery;
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.codegen.model.vo.TablePageVO;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.codegen.service.GenTableService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.codegen.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import com.youlai.boot.codegen.enums.FormTypeEnum;
import com.youlai.boot.codegen.enums.QueryTypeEnum;
import lombok.Getter;

View File

@@ -1,25 +0,0 @@
package com.youlai.boot.common.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口限流
* <p>标注在 Controller 方法上,基于 Redis 计数窗口实现</p>
*
* @author Ray.Hao
* @since 4.3.1
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
int limit() default 0;
int window() default 0;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String prefix() default "rate_limit:";
}

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.base;
import com.youlai.boot.common.annotation.ValidField;
import com.youlai.boot.framework.annotation.ValidField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

View File

@@ -33,9 +33,9 @@ public interface JwtClaimConstants {
String DATA_SCOPES = "dataScopes";
/**
* 权限(角色Code)集合
* 角色编码集合(不带 ROLE_ 前缀)
*/
String AUTHORITIES = "authorities";
String ROLES = "roles";
/**
* Token 版本号

View File

@@ -11,12 +11,12 @@ public interface RedisConstants {
/**
* 限流相关键
*/
interface RateLimiter {
/** 接口级限流 Key示例login:rate_limit:token:/api/v1/auth/login */
String API = "{}rate_limit:{}:{}";
interface RateLimit {
/** 接口级限流 Key示例rate_limit:api:{user}:{uri} */
String API = "rate_limit:{}:{}:{}";
/** IP 全局限流 Key示例rate_limiter:ip:192.168.1.100 */
String IP = "rate_limiter:ip:{}";
/** IP 全局限流 Key示例rate_limit:ip:192.168.1.100 */
String IP = "rate_limit:ip:{}";
}
/**
@@ -43,6 +43,8 @@ public interface RedisConstants {
String REVOKED_JTI = BLACKLIST_TOKEN;
// 用户 Token 版本号(用于按用户失效历史 JWTtoken.tokenVersion != redis.tokenVersion => token 无效
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
// 扫码登录票据上下文ticket -> QrCodeLoginContext
String QR_CODE_LOGIN = "auth:qr_code:{}";
}
/**

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.system.enums;
package com.youlai.boot.common.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.youlai.boot.common.base.IBaseEnum;

View File

@@ -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", "访问未授权"),

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.common.validator;
import com.youlai.boot.common.annotation.ValidField;
import com.youlai.boot.framework.annotation.ValidField;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;

View File

@@ -1,6 +1,6 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.controller;
import cn.hutool.core.util.IdUtil;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.config.FilePath;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.device.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.device.model.entity.SnDeviceInfo;
import com.youlai.boot.device.model.query.DeviceQuery;
import com.youlai.boot.device.model.vo.DevicePageVO;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.device.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import com.youlai.boot.framework.base.BaseEntity;
import lombok.Getter;
import lombok.Setter;

View File

@@ -0,0 +1,52 @@
package com.youlai.boot.file.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 文件存储顶层配置:存储类型与上传限制。
* <p>
* minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
* 此处仅承载 {@code type} 与 {@code upload}。
*/
@Data
@Component
@ConfigurationProperties(prefix = "file-storage")
public class FileStorageProperties {
/** 存储类型minio | aliyun | local */
private String type;
private Upload upload = new Upload();
@Data
public static class Upload {
/** 单文件大小上限,如 50MB同时作为 spring.servlet.multipart 的上限 */
private String maxFileSize;
/** 允许的文件扩展名白名单(置空表示不限制) */
private List<String> allowedExtensions;
}
/**
* 允许扩展名集合(小写、不含点);空集合表示不限制。
*/
public Set<String> getAllowedExtensions() {
if (upload == null || upload.allowedExtensions == null) {
return Collections.emptySet();
}
return upload.allowedExtensions.stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(s -> s.startsWith(".") ? s.substring(1) : s)
.map(String::toLowerCase)
.collect(Collectors.toUnmodifiableSet());
}
}

View File

@@ -1,8 +1,13 @@
package com.youlai.boot.file.controller;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.file.service.FileService;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.file.config.FileStorageProperties;
import com.youlai.boot.file.model.FileInfo;
import com.youlai.boot.file.service.FileService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -13,6 +18,8 @@ import lombok.SneakyThrows;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Set;
/**
* 文件控制层
*
@@ -26,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
public class FileController {
private final FileService fileService;
private final FileStorageProperties fileStorageProperties;
@PostMapping
@Operation(summary = "文件上传")
@@ -39,6 +47,7 @@ public class FileController {
)
@RequestPart(value = "file") MultipartFile file
) {
validateFileExtension(file);
FileInfo fileInfo = fileService.uploadFile(file);
return Result.success(fileInfo);
}
@@ -52,4 +61,19 @@ public class FileController {
boolean result = fileService.deleteFile(filePath);
return Result.judge(result);
}
/**
* 文件扩展名白名单校验:{@code allowed-extensions} 为空时不限制。
*/
private void validateFileExtension(MultipartFile file) {
Set<String> allowedExtensions = fileStorageProperties.getAllowedExtensions();
if (allowedExtensions.isEmpty()) {
return;
}
String suffix = FileUtil.getSuffix(file.getOriginalFilename());
if (StrUtil.isBlank(suffix) || !allowedExtensions.contains(suffix.toLowerCase())) {
throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION,
"不支持的文件类型: " + (StrUtil.isBlank(suffix) ? "" : "." + suffix));
}
}
}

View File

@@ -23,8 +23,8 @@ import java.io.InputStream;
import java.time.LocalDateTime;
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "oss.aliyun")
@ConditionalOnProperty(value = "file-storage.type", havingValue = "aliyun")
@ConfigurationProperties(prefix = "file-storage.aliyun")
@RequiredArgsConstructor
@Data
public class AliyunFileServiceImpl implements FileService {
@@ -32,7 +32,7 @@ public class AliyunFileServiceImpl implements FileService {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
private String bucket;
private OSS aliyunOssClient;
@@ -51,12 +51,12 @@ public class AliyunFileServiceImpl implements FileService {
try (InputStream inputStream = file.getInputStream()) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileName, inputStream, metadata);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, fileName, inputStream, metadata);
aliyunOssClient.putObject(putObjectRequest);
} catch (Exception e) {
throw new RuntimeException("文件上传失败");
}
String fileUrl = "https://" + bucketName + "." + endpoint + "/" + fileName;
String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName;
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
@@ -66,9 +66,9 @@ public class AliyunFileServiceImpl implements FileService {
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
String fileHost = "https://" + bucketName + "." + endpoint;
String fileHost = "https://" + bucket + "." + endpoint;
String fileName = filePath.substring(fileHost.length() + 1);
aliyunOssClient.deleteObject(bucketName, fileName);
aliyunOssClient.deleteObject(bucket, fileName);
return true;
}
}

View File

@@ -22,13 +22,13 @@ import java.time.LocalDateTime;
@Data
@Slf4j
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "local")
@ConfigurationProperties(prefix = "oss.local")
@ConditionalOnProperty(value = "file-storage.type", havingValue = "local")
@ConfigurationProperties(prefix = "file-storage.local")
@RequiredArgsConstructor
public class LocalFileServiceImpl implements FileService {
@Value("${oss.local.storage-path}")
private String storagePath;
@Value("${file-storage.local.path}")
private String path;
@Override
public FileInfo uploadFile(MultipartFile file) {
@@ -36,7 +36,7 @@ public class LocalFileServiceImpl implements FileService {
String suffix = FileUtil.getSuffix(originalFilename);
String fileName = IdUtil.simpleUUID()+ "." + suffix;;
String folder = DateUtil.format(LocalDateTime.now(), DatePattern.PURE_DATE_PATTERN);
String filePrefix = storagePath.endsWith(File.separator) ? storagePath : storagePath + File.separator;
String filePrefix = path.endsWith(File.separator) ? path : path + File.separator;
try (InputStream inputStream = file.getInputStream()) {
FileUtil.writeFromStream(inputStream, filePrefix + folder + File.separator + fileName);
} catch (Exception e) {
@@ -55,9 +55,9 @@ public class LocalFileServiceImpl implements FileService {
if (filePath == null || filePath.isEmpty()) {
return false;
}
if (FileUtil.isDirectory(storagePath + filePath)) {
if (FileUtil.isDirectory(path + filePath)) {
return false;
}
return FileUtil.del(storagePath + filePath);
return FileUtil.del(path + filePath);
}
}

View File

@@ -25,8 +25,8 @@ import java.io.InputStream;
import java.time.LocalDateTime;
@Component
@ConditionalOnProperty(value = "oss.type", havingValue = "minio")
@ConfigurationProperties(prefix = "oss.minio")
@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
@ConfigurationProperties(prefix = "file-storage.minio")
@RequiredArgsConstructor
@Data
@Slf4j
@@ -35,8 +35,8 @@ public class MinioFileServiceImpl implements FileService {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucketName;
private String customDomain;
private String bucket;
private String domain;
private MinioClient minioClient;
@@ -50,7 +50,7 @@ public class MinioFileServiceImpl implements FileService {
@Override
public FileInfo uploadFile(MultipartFile file) {
createBucketIfAbsent(bucketName);
createBucketIfAbsent(bucket);
String originalFilename = file.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename);
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
@@ -58,7 +58,7 @@ public class MinioFileServiceImpl implements FileService {
try (InputStream inputStream = file.getInputStream()) {
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.bucket(bucketName)
.bucket(bucket)
.object(dateFolder + "/"+ fileName)
.contentType(file.getContentType())
.stream(inputStream, inputStream.available(), -1)
@@ -66,16 +66,16 @@ public class MinioFileServiceImpl implements FileService {
minioClient.putObject(putObjectArgs);
String fileUrl;
if (StrUtil.isBlank(customDomain)) {
if (StrUtil.isBlank(domain)) {
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.bucket(bucket)
.object(dateFolder + "/"+ fileName)
.method(Method.GET)
.build();
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
} else {
fileUrl = customDomain + "/"+ bucketName + "/"+ dateFolder + "/"+ fileName;
fileUrl = domain + "/"+ bucket + "/"+ dateFolder + "/"+ fileName;
}
FileInfo fileInfo = new FileInfo();
@@ -93,13 +93,13 @@ public class MinioFileServiceImpl implements FileService {
Assert.notBlank(filePath, "删除文件路径不能为空");
try {
String fileName;
if (StrUtil.isNotBlank(customDomain)) {
fileName = filePath.substring(customDomain.length() + 1 + bucketName.length() + 1);
if (StrUtil.isNotBlank(domain)) {
fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1);
} else {
fileName = filePath.substring(endpoint.length() + 1 + bucketName.length() + 1);
fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1);
}
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName).object(fileName).build());
.bucket(bucket).object(fileName).build());
return true;
} catch (Exception e) {
log.error("删除文件失败", e);
@@ -107,23 +107,23 @@ public class MinioFileServiceImpl implements FileService {
}
}
private static String publicBucketPolicy(String bucketName) {
private static String publicBucketPolicy(String bucket) {
return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
+ "{\"Effect\":\"Allow\"," + "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
}
@SneakyThrows
private void createBucketIfAbsent(String bucketName) {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
private void createBucketIfAbsent(String bucket) {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName).config(publicBucketPolicy(bucketName)).build());
.bucket(bucket).config(publicBucketPolicy(bucket)).build());
}
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.common.annotation;
package com.youlai.boot.framework.annotation;
import java.lang.annotation.*;
@@ -25,4 +25,3 @@ public @interface DataPermission {
String userIdColumnName() default "create_by";
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.common.annotation;
package com.youlai.boot.framework.annotation;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;

View File

@@ -0,0 +1,30 @@
package com.youlai.boot.framework.annotation;
import java.lang.annotation.*;
/**
* 接口限流
* <p>标注在 Controller 方法上,基于 Redis 滑动窗口实现</p>
*
* @author Ray.Hao
* @since 4.3.1
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 窗口内允许的最大请求数。
* <p>
* {@code <=0} 时使用全局默认值 {@code rate-limit.default-limit}。
*/
int limit() default 0;
/**
* 滑动窗口大小(秒)。
* <p>
* {@code <=0} 时使用全局默认值 {@code rate-limit.default-window}。
*/
int window() default 0;
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.common.annotation;
package com.youlai.boot.framework.annotation;
import java.lang.annotation.*;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.common.annotation;
package com.youlai.boot.framework.annotation;
import com.youlai.boot.common.validator.FieldValidator;
import jakarta.validation.Constraint;

View File

@@ -1,12 +1,11 @@
package com.youlai.boot.common.base;
package com.youlai.boot.framework.base;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serial;
import java.io.Serializable;
@@ -15,7 +14,8 @@ import java.time.LocalDateTime;
/**
* 基础实体类
*
* <p>实体类的基类包含了实体类的公共属性如创建时间更新时间逻辑删除标识等</p>
* <p>包含主键和创建/更新时间适用于所有实体含关联表</p>
* <p>审计字段createBy/updateBy和逻辑删除字段isDeleted由需要的实体自行声明</p>
*
* @author Ray.Hao
* @since 4.3.1
@@ -37,7 +37,6 @@ public class BaseEntity implements Serializable {
*/
@TableField(fill = FieldFill.INSERT)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/**
@@ -45,7 +44,6 @@ public class BaseEntity implements Serializable {
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}

View File

@@ -24,12 +24,10 @@ public class RedisConfig {
* 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer
*
* @param redisConnectionFactory {@link RedisConnectionFactory}
* @param jsonMapper Jackson 序列化器(统一使用不写入类型信息的配置)
* @return {@link RedisTemplate}
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory,
JsonMapper jsonMapper) {
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
@@ -38,7 +36,9 @@ public class RedisConfig {
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
// Value 使用自定义 JSON 序列化(不写入类型信息,避免 HashSet 等集合被序列化成带 @class 的结构)
JsonMapper jsonMapper = JsonMapper.builder()
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
JacksonJsonRedisSerializer<Object> jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class);
redisTemplate.setValueSerializer(jsonSerializer);
@@ -48,17 +48,4 @@ public class RedisConfig {
return redisTemplate;
}
/**
* 统一的 JsonMapper Bean
* <p>
* 禁止将日期序列化为时间戳;不写入类型信息,保证 Redis 存储的 JSON 纯净可读。
* 需要反序列化到特定类型时,调用方应使用 {@link JsonMapper#convertValue(Object, Class)} 显式转换。
*/
@Bean
public JsonMapper jsonMapper() {
return JsonMapper.builder()
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
}
}

View File

@@ -1,6 +1,7 @@
package com.youlai.boot.framework.mybatis.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.youlai.boot.framework.security.util.SecurityUtils;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
@@ -9,6 +10,9 @@ import java.time.LocalDateTime;
/**
* MyBatis-Plus 字段自动填充
*
* <p>自动填充 createTime/updateTime时间戳和 createBy/updateBy操作人ID</p>
* <p>实体字段需标注 {@code @TableField(fill = FieldFill.INSERT)} 或 {@code FieldFill.INSERT_UPDATE} 才会触发</p>
*
* @author Ray.Hao
* @since 3.0.0
*/
@@ -18,11 +22,14 @@ public class AutoFillMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
this.strictInsertFill(metaObject, "createBy", SecurityUtils::getUserId, Long.class);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
this.strictUpdateFill(metaObject, "updateBy", SecurityUtils::getUserId, Long.class);
}
}

View File

@@ -4,10 +4,10 @@ import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.framework.annotation.DataPermission;
import com.youlai.boot.common.enums.DataScopeEnum;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -58,7 +58,7 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
// 获取当前用户的数据权限列表
List<RoleDataScope> dataScopes = SecurityUtils.getUser()
.map(SysUserDetails::getDataScopes)
.map(SecurityUserDetails::getDataScopes)
.orElse(List.of());
// 如果任一角色是 ALL则跳过数据权限过滤并集策略

View File

@@ -2,78 +2,72 @@ package com.youlai.boot.framework.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.security.token.TokenManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* Token 认证校验过滤器
* Token 认证过滤器
* <p>
* 仅负责解析 Token 和填充 {@link SecurityContextHolder}。
* 无效 Token 抛出 {@link AuthenticationException},由使用方的
* {@code AuthenticationEntryPoint} 统一处理响应格式。
* <p>
* 必须注册在 {@code ExceptionTranslationFilter} 之后(即 {@code AuthorizationFilter} 之前),
* 这样抛出的异常才能被 {@code ExceptionTranslationFilter} 捕获。
*
* @author wangtao
* @since 2025/3/6 16:50
* @since 2025/3/6
*/
public class TokenAuthenticationFilter extends OncePerRequestFilter {
/**
* Token 管理器
*/
private final TokenManager tokenManager;
public TokenAuthenticationFilter(TokenManager tokenManager) {
this.tokenManager = tokenManager;
}
/**
* 校验 Token ,包括验签和是否过期
* 如果 Token 有效,将 Token 解析为 Authentication 对象,并设置到 Spring Security 上下文中
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String rawToken = resolveToken(request);
try {
if (StrUtil.isNotBlank(rawToken)) {
// 执行令牌有效性检查(包含密码学验签和过期时间验证)
boolean isValidToken = tokenManager.validateToken(rawToken);
if (!isValidToken) {
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
if (StrUtil.isNotBlank(rawToken)) {
try {
boolean isValid = tokenManager.validateToken(rawToken);
if (!isValid) {
SecurityContextHolder.clearContext();
throw new InsufficientAuthenticationException("Token 无效或已过期");
}
// 将令牌解析为 Spring Security 上下文认证对象
Authentication authentication = tokenManager.parseToken(rawToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (AuthenticationException ex) {
SecurityContextHolder.clearContext();
throw ex;
} catch (Exception ex) {
SecurityContextHolder.clearContext();
throw new InsufficientAuthenticationException("Token 认证失败", ex);
}
} catch (Exception ex) {
// 安全上下文清除保障(防止上下文残留)
SecurityContextHolder.clearContext();
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
}
// 继续后续过滤器链执行
filterChain.doFilter(request, response);
}
/**
* 从请求中解析 Token仅支持 Authorization Header
*/
private String resolveToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(authorizationHeader)
&& authorizationHeader.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return authorizationHeader.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
return null;
}

View File

@@ -6,16 +6,16 @@ import java.util.List;
import java.util.Set;
/**
* 用户认证信息
* 安全模块用户数据 POJO
* <p>
* 用于登录认证过程中的用户信息承载包含用户名密码状态角色等与认证/授权相关的数据
* </p>
* 作为端口接口的返回类型承载用户认证所需的全部数据
* JDK 类型 system 模块依赖可直接序列化
*
* @author Ray.Hao
* @since 2025/12/16
*/
@Data
public class UserAuthInfo {
public class SecurityUser {
/**
* 用户ID
@@ -48,7 +48,12 @@ public class UserAuthInfo {
private Integer status;
/**
* 角色集合
* 头像 URL
*/
private String avatar;
/**
* 角色编码集合
*/
private Set<String> roles;

View File

@@ -13,17 +13,18 @@ import java.util.*;
import java.util.stream.Collectors;
/**
* Spring Security 用户认证对象
* Spring Security 用户认证对象
* <p>
* 封装了用户的基本信息和权限信息 Spring Security 进行用户认证与授权
* 实现了 {@link UserDetails} 接口提供用户的核心信息
* 实现 {@link UserDetails}封装用户标识角色数据权限等认证信息
* {@link #roles} 字段存储角色编码不带 ROLE_ 前缀
* {@link #getAuthorities()} 运行时补前缀并转为 {@link SimpleGrantedAuthority}
*
* @author Ray.Hao
* @version 3.0.0
*/
@Data
@NoArgsConstructor
public class SysUserDetails implements UserDetails {
public class SecurityUserDetails implements UserDetails {
/**
* 用户ID
@@ -52,42 +53,35 @@ public class SysUserDetails implements UserDetails {
/**
* 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围用于实现多角色权限合并并集策略
*/
private List<RoleDataScope> dataScopes;
/**
* 用户角色权限集合
* 角色编码集合不带 ROLE_ 前缀
*/
private Collection<SimpleGrantedAuthority> authorities;
private Set<String> roles;
/**
* 构造函数根据用户认证信息初始化用户详情对象
*
* @param user 用户认证信息对象 {@link UserAuthInfo}
* 构造函数根据 {@link SecurityUser} 初始化
*/
public SysUserDetails(UserAuthInfo user) {
public SecurityUserDetails(SecurityUser user) {
this.userId = user.getUserId();
this.username = user.getUsername();
this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId();
this.dataScopes = user.getDataScopes();
// 初始化角色权限集合
this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
? user.getRoles().stream()
// 角色名加上前缀 "ROLE_"用于区分角色 (ROLE_ADMIN) 和权限 (user:add)
.map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
.collect(Collectors.toSet())
: Collections.emptySet();
this.roles = user.getRoles();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
if (CollectionUtil.isEmpty(roles)) {
return Collections.emptySet();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority(SecurityConstants.ROLE_PREFIX + role))
.collect(Collectors.toSet());
}
@Override
@@ -107,8 +101,6 @@ public class SysUserDetails implements UserDetails {
/**
* 判断是否包含"全部数据"权限
*
* @return 是否有全部数据权限
*/
public boolean hasAllDataScope() {
if (CollectionUtil.isEmpty(dataScopes)) {
@@ -119,9 +111,7 @@ public class SysUserDetails implements UserDetails {
}
/**
* 获取数据权限列表
*
* @return 数据权限列表永不为null
* 获取数据权限列表永不为 null
*/
public List<RoleDataScope> getDataScopes() {
return dataScopes != null ? dataScopes : Collections.emptyList();

View File

@@ -1,49 +0,0 @@
package com.youlai.boot.framework.security.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Set;
/**
* 用户会话信息
* <p>
* 存储在Token中的用户会话快照包含用户身份、数据权限和角色权限信息。
* 用于Redis-Token模式下的会话管理支持在线用户查询和会话控制。
*
* @author wangtao
* @since 2025/2/27 10:31
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserSession {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 部门ID
*/
private Long deptId;
/**
* 数据权限列表
*/
private List<RoleDataScope> dataScopes;
/**
* 角色权限集合
*/
private Set<String> roles;
}

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.framework.security.port;
import java.util.Set;
/**
* 权限查询端口。
* <p>
* 由 system 模块提供适配器实现framework 层通过此接口获取角色权限集合,
* 不直接依赖 system 模块的 {@code RoleMenuService}。
*
* @see com.youlai.boot.system.security.adapter.PermissionAdapter
*/
public interface PermissionPort {
/**
* 根据角色编码集合查询权限标识集合。
*
* @param roleCodes 角色编码集合
* @return 权限标识集合,如 "sys:user:create"
*/
Set<String> getRolePerms(Set<String> roleCodes);
}

View File

@@ -0,0 +1,40 @@
package com.youlai.boot.framework.security.port;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
/**
* 用户认证信息查询端口。
* <p>
* 由 system 模块提供适配器实现framework 层通过此接口获取认证数据,
* 不直接依赖 system 模块的 {@code UserService} / {@code UserSocialService}。
*
* @see com.youlai.boot.system.security.adapter.UserAuthenticationAdapter
*/
public interface UserAuthenticationPort {
/**
* 根据用户名查询认证信息。
*
* @param username 用户名
* @return 认证信息,不存在返回 null
*/
SecurityUser getAuthInfoByUsername(String username);
/**
* 根据手机号查询认证信息。
*
* @param mobile 手机号
* @return 认证信息,不存在返回 null
*/
SecurityUser getAuthInfoByMobile(String mobile);
/**
* 根据第三方平台 openid 查询认证信息。
*
* @param platform 第三方平台
* @param openid openid
* @return 认证信息,未绑定返回 null
*/
SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid);
}

View File

@@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.service;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.framework.security.port.PermissionPort;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -12,11 +12,10 @@ import org.springframework.util.PatternMatchUtils;
import java.util.Set;
/**
* Spring Security 权限校验组件
* Spring Security 权限校验组件
* <p>
* 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:create')")
* <p>
* 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
* 用于 SpEL 表达式{@code @PreAuthorize("@ss.hasPerm('sys:user:create')")}。
* 通过 {@link PermissionPort} 查询角色权限,不直接依赖 system 模块。
*
* @author Ray.Hao
* @since 0.0.1
@@ -26,39 +25,30 @@ import java.util.Set;
@Slf4j
public class PermissionService {
private final RoleMenuService roleMenuService;
private final PermissionPort permissionPort;
/**
* 判断当前登录用户是否拥有操作权限
* <p>
* 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:create"、"sys:user:delete" 等
*
* @param requiredPerm 所需权限
* @return 是否有权限
* 判断当前用户是否拥有操作权限,支持通配符匹配。
*/
public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) {
return false;
}
// 超级管理员放行
if (SecurityUtils.isRoot()) {
return true;
}
// 获取当前登录用户的角色编码集合
Set<String> roleCodes = SecurityUtils.getRoles();
if (CollectionUtil.isEmpty(roleCodes)) {
return false;
}
// 获取当前登录用户的所有角色的权限列表(从缓存读取)
Set<String> rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
Set<String> rolePerms = permissionPort.getRolePerms(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) {
return false;
}
// 判断权限列表中是否包含所需权限(支持通配符)
boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));

View File

@@ -0,0 +1,41 @@
package com.youlai.boot.framework.security.service;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
* 系统用户认证 DetailsService。
* <p>
* 通过 {@link UserAuthenticationPort} 获取认证信息,不直接依赖 system 模块。
*
* @author Ray.Hao
* @since 2021/10/19
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SecurityUserDetailsService implements UserDetailsService {
private final UserAuthenticationPort userAuthPort;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
SecurityUser securityUser = userAuthPort.getAuthInfoByUsername(username);
if (securityUser == null) {
throw new UsernameNotFoundException(username);
}
return new SecurityUserDetails(securityUser);
} catch (Exception e) {
log.error("认证异常:{}", e.getMessage());
throw e;
}
}
}

View File

@@ -1,82 +0,0 @@
package com.youlai.boot.framework.security.service;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.UserAuthInfo;
import com.youlai.boot.system.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
/**
* 系统用户认证 DetailsService
*
* @author Ray.Hao
* @since 2021/10/19
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SysUserDetailsService implements UserDetailsService {
private final UserService userService;
private final UserSocialService userSocialService;
/**
* 根据用户名获取用户信息
*
* @param username 用户名
* @return 用户信息
* @throws UsernameNotFoundException 用户名未找到异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username);
if (userAuthInfo == null) {
throw new UsernameNotFoundException(username);
}
return new SysUserDetails(userAuthInfo);
} catch (Exception e) {
// 记录异常日志
log.error("认证异常:{}", e.getMessage());
// 抛出异常
throw e;
}
}
/**
* 根据微信小程序openid查询绑定信息
*
* @param openid 微信小程序openid
* @return 绑定信息未绑定返回null
*/
public UserSocial getWechatMiniBindInfo(String openid) {
return userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
}
/**
* 根据微信小程序openid获取用户认证信息
*
* @param openid 微信小程序openid
* @return 用户认证信息用户不存在返回null
*/
public UserAuthInfo getAuthInfoByWechatOpenid(String openid) {
return userSocialService.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid);
}
/**
* 更新微信小程序session_key
*
* @param bindId 绑定记录ID
* @param sessionKey session_key
*/
public void updateWechatSessionKey(Long bindId, String sessionKey) {
userSocialService.updateSessionKey(bindId, sessionKey);
}
}

View File

@@ -17,30 +17,25 @@ import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.apache.commons.lang3.StringUtils;
import com.youlai.boot.framework.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit; // Import TimeUnit
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* JWT Token 管理器
* JWT Token 管理器
* <p>
* 实现基于JWT的无状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>Token 撤销jti黑名单</li>
* <li>用户级会话失效tokenVersion</li>
* <li>多角色数据权限存储</li>
* </ul>
* 基于 JWT 的无状态认证,支持 Access + Refresh 双令牌、Token 撤销jti 黑名单)、
* 用户级会话失效tokenVersion、多角色数据权限存储。
* <p>
* JWT claims 中存储角色编码(不带 ROLE_ 前缀),解析后由 {@link SecurityUserDetails#getAuthorities()} 运行时补前缀。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -59,12 +54,6 @@ public class JwtTokenManager implements TokenManager {
this.secretKey = securityProperties.getSession().getJwt().getSecretKey().getBytes();
}
/**
* 生成令牌
*
* @param authentication 认证信息
* @return 令牌响应对象
*/
@Override
public AuthenticationToken generateToken(Authentication authentication) {
int accessTokenTimeToLive = securityProperties.getSession().getAccessTokenTimeToLive();
@@ -81,22 +70,15 @@ public class JwtTokenManager implements TokenManager {
.build();
}
/**
* 解析令牌
*
* @param token JWT Token
* @return Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads();
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
SecurityUserDetails userDetails = new SecurityUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID));
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID));
// 解析数据权限列表
// 数据权限
JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES);
if (dataScopesArray != null && !dataScopesArray.isEmpty()) {
List<RoleDataScope> dataScopes = dataScopesArray.stream()
@@ -115,88 +97,60 @@ public class JwtTokenManager implements TokenManager {
userDetails.setDataScopes(dataScopes);
}
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合
Set<SimpleGrantedAuthority> authorities = payloads.getJSONArray(JwtClaimConstants.AUTHORITIES)
.stream()
.map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
.collect(Collectors.toSet());
userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT));
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
// 角色编码(不带 ROLE_ 前缀)
JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES);
if (rolesArray != null && !rolesArray.isEmpty()) {
Set<String> roles = rolesArray.stream()
.map(Convert::toStr)
.collect(Collectors.toSet());
userDetails.setRoles(roles);
}
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* 校验令牌
*
* @param token JWT Token
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
return validateToken(token, false);
}
/**
* 校验刷新令牌
*
* @param refreshToken JWT Token
* @return 验证结果
*/
@Override
public boolean validateRefreshToken(String refreshToken) {
return validateToken(refreshToken, true);
}
/**
* 校验令牌
* <p>
* 校验流程(按顺序执行):
* <ol>
* <li>签名验证 + 过期时间检查</li>
* <li>刷新令牌类型校验(仅刷新场景)</li>
* <li>tokenVersion 校验(用户级会话失效)</li>
* <li>jti 黑名单校验单Token撤销</li>
* </ol>
*
* @param token JWT Token
* @param validateRefreshToken 是否校验刷新令牌类型
* @return 是否有效
*/
private boolean validateToken(String token, boolean validateRefreshToken) {
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
JSONObject payloads = jwt.getPayloads();
// 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用)
// 刷新令牌类型校验
String jti = payloads.getStr(JWTPayload.JWT_ID);
if (validateRefreshToken) {
//刷新token需要校验token类别
boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE);
if (!isRefreshToken) {
return false;
}
}
// 2. 校验 tokenVersion用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动"踢所有端"后,递增 tokenVersion
// 之前签发的 Token 因版本号不匹配而失效
// tokenVersion 校验(用户维度 Token 失效)
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) {
Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION);
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
Object currentVersionObj = redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0;
// 版本号不匹配则 Token 无效(新签发的 Token 版本号必须 >= Redis 中的版本号)
if (tokenVersion == null || tokenVersion < currentVersion) {
return false;
}
}
// 3. 判断 Token 是否已被撤销(单端退出/会话注销)
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
// jti 黑名单校验
if (isTokenRevoked(jti)) {
return false;
}
@@ -204,17 +158,11 @@ public class JwtTokenManager implements TokenManager {
return isValid;
}
/**
* 将令牌加入黑名单
*
* @param token JWT Token
*/
@Override
public void invalidateToken(String token) {
if (StringUtils.isBlank(token)) {
return;
}
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length());
}
@@ -225,77 +173,15 @@ public class JwtTokenManager implements TokenManager {
revokeTokenByJti(jti, expirationAt);
}
/**
* 检查Token是否已被撤销
*
* @param jti Token唯一标识
* @return true-已撤销false-未撤销
*/
private boolean isTokenRevoked(String jti) {
if (StringUtils.isBlank(jti)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
}
/**
* 将Token加入撤销黑名单
* <p>
* 黑名单有效期与Token剩余有效期一致避免永久存储
*
* @param jti Token唯一标识
* @param expirationAt Token过期时间戳
*/
private void revokeTokenByJti(String jti, Integer expirationAt) {
if (StringUtils.isBlank(jti)) {
return;
}
String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
return;
}
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
}
}
/**
* 失效指定用户的所有会话
* <p>
* 通过递增用户 tokenVersion使该用户之前签发的所有 Token 因版本号不匹配而失效。
* <p>
* 适用场景:
* <ul>
* <li>用户修改密码</li>
* <li>管理员强制下线用户</li>
* <li>用户主动踢出所有设备</li>
* <li>用户被禁用</li>
* </ul>
*
* @param userId 用户ID
*/
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId);
// 递增版本号,无需设置 TTL版本号永久有效避免 TTL 过期导致的安全问题)
redisTemplate.opsForValue().increment(versionKey);
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 令牌响应对象
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
boolean isValid = validateRefreshToken(refreshToken);
@@ -313,45 +199,19 @@ public class JwtTokenManager implements TokenManager {
.build();
}
/**
* 生成 JWT Token
*
* @param authentication 认证信息
* @param ttl 过期时间(秒),-1表示永不过期
* @return JWT Token字符串
*/
// ======================== private ========================
private String generateToken(Authentication authentication, int ttl) {
return generateToken(authentication, ttl, false);
}
/**
* 生成 JWT Token
* <p>
* Payload包含
* <ul>
* <li>userId - 用户ID</li>
* <li>deptId - 部门ID</li>
* <li>dataScopes - 数据权限列表</li>
* <li>authorities - 角色权限集合</li>
* <li>tokenType - 是否为刷新令牌</li>
* <li>tokenVersion - Token版本号用于会话失效控制</li>
* <li>iat/exp - 签发/过期时间</li>
* <li>jti - Token唯一标识用于撤销</li>
* </ul>
*
* @param authentication 认证信息
* @param ttl 过期时间(秒)
* @param isRefreshToken 是否为刷新令牌
* @return JWT Token字符串
*/
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId());
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId());
// 存储数据权限列表
// 数据权限
List<RoleDataScope> dataScopes = userDetails.getDataScopes();
if (dataScopes != null && !dataScopes.isEmpty()) {
List<Map<String, Object>> scopesList = dataScopes.stream()
@@ -366,13 +226,12 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.DATA_SCOPES, scopesList);
}
// claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
payload.put(JwtClaimConstants.AUTHORITIES, roles);
// 角色编码(不带 ROLE_ 前缀)
Set<String> roles = userDetails.getRoles();
if (roles != null && !roles.isEmpty()) {
payload.put(JwtClaimConstants.ROLES, roles);
}
// 获取当前用户的 Token 版本号,用于会话失效控制
Long userId = userDetails.getUserId();
int tokenVersion = 0;
if (userId != null) {
@@ -384,12 +243,8 @@ public class JwtTokenManager implements TokenManager {
Date now = new Date();
payload.put(JWTPayload.ISSUED_AT, now);
payload.put(JwtClaimConstants.TOKEN_TYPE, false);
if (isRefreshToken) {
payload.put(JwtClaimConstants.TOKEN_TYPE, true);
}
payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken);
// 设置过期时间 -1 表示永不过期
if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl);
payload.put(JWTPayload.EXPIRES_AT, expiresAt);
@@ -400,4 +255,27 @@ public class JwtTokenManager implements TokenManager {
return JWTUtil.createToken(payload, secretKey);
}
private boolean isTokenRevoked(String jti) {
if (StringUtils.isBlank(jti)) {
return false;
}
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
}
private void revokeTokenByJti(String jti, Integer expirationAt) {
if (StringUtils.isBlank(jti)) {
return;
}
String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti);
if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) {
return;
}
int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
}
}
}

View File

@@ -1,6 +1,5 @@
package com.youlai.boot.framework.security.token;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
@@ -9,35 +8,25 @@ import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.UserSession;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;
import tools.jackson.databind.json.JsonMapper;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Redis Token 管理器
* Redis Token 管理器
* <p>
* 实现基于Redis的有状态认证,支持:
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>单设备/多设备登录控制</li>
* <li>用户级会话失效</li>
* <li>在线用户管理</li>
* </ul>
* 基于 Redis 的有状态 Token 认证,支持 Access + Refresh 双令牌、
* 单/多设备登录控制、用户级会话失效。
* <p>
* 与JWT模式相比Redis模式支持主动踢人、在线用户查询等功能
* 直接存取 {@link SecurityUserDetails}password 置 null无需 UserSession 中间层。
*
* @author Ray.Hao
* @since 2024/11/15
@@ -58,33 +47,16 @@ public class RedisTokenManager implements TokenManager {
this.jsonMapper = jsonMapper;
}
/**
* 生成 Token
*
* @param authentication 用户认证信息
* @return 生成的 AuthenticationToken 对象
*/
@Override
public AuthenticationToken generateToken(Authentication authentication) {
SysUserDetails user = (SysUserDetails) authentication.getPrincipal();
SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal();
String accessToken = IdUtil.fastSimpleUUID();
String refreshToken = IdUtil.fastSimpleUUID();
// 构建用户会话信息
UserSession userSession = new UserSession(
user.getUserId(),
user.getUsername(),
user.getDeptId(),
user.getDataScopes(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
);
// 构建会话快照(不存密码)
SecurityUserDetails sessionUser = buildSessionUser(user);
// 存储访问令牌、刷新令牌和刷新令牌映射
storeTokensInRedis(accessToken, refreshToken, userSession);
// 单设备登录控制
storeTokensInRedis(accessToken, refreshToken, sessionUser);
handleSingleDeviceLogin(user.getUserId(), accessToken);
return AuthenticationToken.builder()
@@ -94,61 +66,24 @@ public class RedisTokenManager implements TokenManager {
.build();
}
/**
* 根据 token 解析用户信息
*
* @param token Redis Token
* @return 构建的 Authentication 对象
*/
@Override
public Authentication parseToken(String token) {
Object raw = redisTemplate.opsForValue().get(formatTokenKey(token));
if (raw == null) return null;
UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
// 构建用户权限集合
Set<SimpleGrantedAuthority> authorities = null;
Set<String> roles = userSession.getRoles();
if (CollectionUtil.isNotEmpty(roles)) {
authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
}
// 构建用户详情对象
SysUserDetails userDetails = buildUserDetails(userSession, authorities);
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
/**
* 校验 Token 是否有效
*
* @param token 访问令牌
* @return 是否有效
*/
@Override
public boolean validateToken(String token) {
return redisTemplate.hasKey(formatTokenKey(token));
}
/**
* 校验 RefreshToken 是否有效
*
* @param refreshToken 访问令牌
* @return 是否有效
*/
@Override
public boolean validateRefreshToken(String refreshToken) {
return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken));
}
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 新生成的 AuthenticationToken 对象
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
Object raw = redisTemplate.opsForValue()
@@ -156,16 +91,16 @@ public class RedisTokenManager implements TokenManager {
if (raw == null) {
throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID);
}
UserSession userSession = jsonMapper.convertValue(raw, UserSession.class);
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
// 删除旧的访问令牌记录
SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class);
Object oldAccessTokenValue = redisTemplate.opsForValue()
.get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()));
Optional.of(oldAccessTokenValue)
.map(String.class::cast)
.ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
// 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID();
storeAccessToken(newAccessToken, userSession);
storeAccessToken(newAccessToken, sessionUser);
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
return AuthenticationToken.builder()
@@ -175,164 +110,91 @@ public class RedisTokenManager implements TokenManager {
.build();
}
/**
* Make access token invalid
* <p>
* Only deletes the current token, not all sessions for the user.
* This ensures single-device logout doesn't affect other devices when allowMultiLogin=true.
*
* @param token Access token
*/
@Override
public void invalidateToken(String token) {
String cleanToken = cleanBearerPrefix(token);
// Only delete the current token, not all user sessions
redisTemplate.delete(formatTokenKey(cleanToken));
}
/**
* 使指定用户的所有会话失效
* <p>
* 适用场景:用户修改密码、管理员强制下线、账号封禁等
*
* @param userId 用户ID
*/
@Override
public void invalidateUserSessions(Long userId) {
if (userId == null) {
return;
}
// 1. 删除访问令牌相关
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (accessTokenValue instanceof String accessToken) {
redisTemplate.delete(formatTokenKey(accessToken));
}
// 无论是否存在访问令牌映射,都尝试删除 userAccessKey
redisTemplate.delete(userAccessKey);
// 2. 删除刷新令牌相关
String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey);
if (refreshTokenValue instanceof String refreshToken) {
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
}
// 同样清理 userRefreshKey 本身
redisTemplate.delete(userRefreshKey);
}
/**
* 将访问令牌和刷新令牌存储至 Redis
*
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @param userSession 用户会话信息
*/
private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) {
// 访问令牌 -> 用户信息
setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
// ======================== private ========================
// 刷新令牌 -> 用户信息
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
// 用户ID -> 刷新令牌
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
refreshToken,
securityProperties.getSession().getRefreshTokenTimeToLive());
private SecurityUserDetails buildSessionUser(SecurityUserDetails user) {
SecurityUserDetails sessionUser = new SecurityUserDetails();
sessionUser.setUserId(user.getUserId());
sessionUser.setUsername(user.getUsername());
sessionUser.setDeptId(user.getDeptId());
sessionUser.setDataScopes(user.getDataScopes());
sessionUser.setRoles(user.getRoles());
sessionUser.setEnabled(user.isEnabled());
sessionUser.setPassword(null);
return sessionUser;
}
private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) {
setRedisValue(formatTokenKey(accessToken), sessionUser,
securityProperties.getSession().getAccessTokenTimeToLive());
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, sessionUser,
securityProperties.getSession().getRefreshTokenTimeToLive());
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()),
refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive());
}
/**
* 处理单设备登录控制
* <p>
* 当配置不允许多设备登录时新登录会使旧Token失效
*
* @param userId 用户ID
* @param accessToken 新生成的访问令牌
*/
private void handleSingleDeviceLogin(Long userId, String accessToken) {
Boolean allowMultiLogin = securityProperties.getSession().getRedisToken().getAllowMultiLogin();
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
// 单设备登录控制,删除旧的访问令牌
if (!allowMultiLogin) {
Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
if (oldAccessTokenValue instanceof String oldAccessToken) {
redisTemplate.delete(formatTokenKey(oldAccessToken));
}
}
// 存储访问令牌映射用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌
setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
/**
* 存储新的访问令牌
*
* @param newAccessToken 新访问令牌
* @param userSession 用户会话信息
*/
private void storeAccessToken(String newAccessToken, UserSession userSession) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser,
securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId());
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
}
/**
* 构建用户详情对象
*
* @param userSession 用户会话信息
* @param authorities 权限集合
* @return SysUserDetails 用户详情
*/
private SysUserDetails buildUserDetails(UserSession userSession, Set<SimpleGrantedAuthority> authorities) {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(userSession.getUserId());
userDetails.setUsername(userSession.getUsername());
userDetails.setDeptId(userSession.getDeptId());
userDetails.setDataScopes(userSession.getDataScopes());
userDetails.setAuthorities(authorities);
return userDetails;
}
/**
* 格式化访问令牌的 Redis 键
*
* @param token 访问令牌
* @return 格式化后的 Redis 键
*/
private String formatTokenKey(String token) {
return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token);
}
/**
* 格式化刷新令牌的 Redis 键
*
* @param refreshToken 访问令牌
* @return 格式化后的 Redis 键
*/
private String formatRefreshTokenKey(String refreshToken) {
return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
}
/**
* 将值存储到 Redis
*
* @param key 键
* @param value 值
* @param ttl 过期时间(秒),-1表示永不过期
*/
private void setRedisValue(String key, Object value, int ttl) {
if (ttl != -1) {
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, value); // ttl=-1时永不过期
redisTemplate.opsForValue().set(key, value);
}
}
/**
* 清理 Bearer 前缀
*/
private String cleanBearerPrefix(String token) {
if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) {
return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim();

View File

@@ -1,21 +1,16 @@
package com.youlai.boot.framework.security.util;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.framework.security.model.RoleDataScope;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.*;
import java.util.stream.Collectors;
/**
* Spring Security 工具类
@@ -26,101 +21,73 @@ import java.util.stream.Collectors;
public class SecurityUtils {
/**
* 获取当前登录人信息
*
* @return Optional<SysUserDetails>
* 获取当前登录用户
*/
public static Optional<SysUserDetails> getUser() {
public static Optional<SecurityUserDetails> getUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
Object principal = authentication.getPrincipal();
if (principal instanceof SysUserDetails) {
return Optional.of((SysUserDetails) principal);
if (principal instanceof SecurityUserDetails) {
return Optional.of((SecurityUserDetails) principal);
}
}
return Optional.empty();
}
/**
* 获取用户ID
*
* @return Long
*/
public static Long getUserId() {
return getUser().map(SysUserDetails::getUserId).orElse(null);
return getUser().map(SecurityUserDetails::getUserId).orElse(null);
}
/**
* 获取用户账号
*
* @return String 用户账号
*/
public static String getUsername() {
return getUser().map(SysUserDetails::getUsername).orElse(null);
return getUser().map(SecurityUserDetails::getUsername).orElse(null);
}
/**
* 获取部门ID
*
* @return Long
*/
public static Long getDeptId() {
return getUser().map(SysUserDetails::getDeptId).orElse(null);
return getUser().map(SecurityUserDetails::getDeptId).orElse(null);
}
/**
* 获取数据权限列表
*
* @return 数据权限列表
*/
public static List<RoleDataScope> getDataScopes() {
return getUser().map(SysUserDetails::getDataScopes).orElse(List.of());
return getUser().map(SecurityUserDetails::getDataScopes).orElse(List.of());
}
/**
* 获取角色集合
*
* @return 角色集合
* 获取角色编码集合,不带 ROLE_ 前缀。
* <p>
* 直接从 {@link SecurityUserDetails#getRoles()} 取值,不做 stripping 和过滤。
*/
public static Set<String> getRoles() {
return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getAuthorities)
.filter(CollectionUtil::isNotEmpty)
.stream()
.flatMap(Collection::stream)
.map(GrantedAuthority::getAuthority)
// 筛选角色,authorities 中的角色都是以 ROLE_ 开头
.filter(authority -> authority.startsWith(SecurityConstants.ROLE_PREFIX))
.map(authority -> StrUtil.removePrefix(authority, SecurityConstants.ROLE_PREFIX))
.collect(Collectors.toSet());
return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of());
}
/**
* 是否超级管理员
* <p>
* 超级管理员忽视任何权限判断
*/
public static boolean isRoot() {
Set<String> roles = getRoles();
return roles.contains(SystemConstants.ROOT_ROLE_CODE);
return getRoles().contains(SystemConstants.ROOT_ROLE_CODE);
}
/**
* 获取请求中的 Token
*
* @return Token 字符串
* 从请求头获取 Token
*/
public static String getAccessToken() {
ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
if(Objects.isNull(servletRequestAttributes)) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
HttpServletRequest request = servletRequestAttributes.getRequest();
HttpServletRequest request = attributes.getRequest();
return request.getHeader(HttpHeaders.AUTHORIZATION);
}
}

View File

@@ -5,6 +5,7 @@ import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.web.exception.RateLimitException;
import jakarta.servlet.ServletException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -157,6 +158,12 @@ public class GlobalExceptionHandler {
return Result.failed(e.getResultCode());
}
@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public <T> Result<T> handleRateLimitException(RateLimitException e) {
return Result.failed(e.getResultCode(), e.getMessage());
}
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.OK)
public <T> Result<T> handleBizException(BusinessException e) {

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.util.IPUtils;

View File

@@ -2,13 +2,13 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties;
import com.youlai.boot.framework.web.exception.RateLimitException;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -55,8 +55,9 @@ public class RateLimitAspect {
String key = buildKey(request, rateLimit);
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
long windowMs = rateLimit.timeUnit().toMillis(window);
long windowMs = rateLimit.window() > 0
? rateLimit.window() * 1000L
: rateLimitProperties.getDefaultWindow().toMillis();
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
@@ -65,7 +66,7 @@ public class RateLimitAspect {
if (current > limit) {
log.warn("接口限流触发 key={} count={} limit={}", key, current, limit);
throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
throw new RateLimitException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
}
return jp.proceed();
@@ -73,8 +74,7 @@ public class RateLimitAspect {
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String user = resolveUser(request);
return StrUtil.format(RedisConstants.RateLimiter.API,
rateLimit.prefix(), user, request.getRequestURI());
return StrUtil.format(RedisConstants.RateLimit.API, user, request.getRequestURI());
}
private String resolveUser(HttpServletRequest request) {

View File

@@ -2,11 +2,12 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.annotation.RepeatSubmit;
import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.util.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -42,7 +43,7 @@ public class RepeatSubmitAspect {
@Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit")
public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
String lockKey = buildLockKey();
String lockKey = buildLockKey(pjp);
int expire = repeatSubmit.expire();
RLock lock = redissonClient.getLock(lockKey);
@@ -56,12 +57,17 @@ public class RepeatSubmitAspect {
/**
* 生成防重复提交锁的 key
* <p>
* key 由「用户标识 + 接口(method:URI) + 请求体哈希」组成:只有同一用户在同一接口提交
* 完全相同的请求体才视为重复提交;若仅按 method:URI 生成,同一接口的不同提交
* (如新增菜单与新增目录)会在窗口内被误判为重复。
*/
private String buildLockKey() {
private String buildLockKey(ProceedingJoinPoint pjp) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String userIdentifier = getUserIdentifier(request);
String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI());
return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier);
String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs()));
return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash);
}
/**

View File

@@ -4,11 +4,11 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 限流配置
* <p>
* 对应 application.yml 中的 {@code rate-limit} 配置节点。
* </p>
* <p>对应 application.yml 中的 {@code rate-limit} 配置节点。</p>
*
* @author Ray.Hao
* @since 4.3.1
@@ -19,14 +19,14 @@ import org.springframework.stereotype.Component;
public class RateLimitProperties {
/**
* @RateLimit 注解未显式指定 limit 时的默认阈值
* @RateLimit 注解未显式指定 limit 时的默认阈值(窗口内最大请求数)
*/
private int defaultLimit = 5;
/**
* @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
* @RateLimit 注解未显式指定 window 时的默认窗口大小,支持 60s / 1m / 500ms 等 Duration 写法
*/
private int defaultWindow = 1;
private Duration defaultWindow = Duration.ofSeconds(60);
/**
* IP 全局限流配置
@@ -48,9 +48,9 @@ public class RateLimitProperties {
private int limit = 1000;
/**
* 窗口大小(秒,默认 60
* 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s
*/
private int windowSeconds = 60;
private Duration window = Duration.ofSeconds(60);
}

View File

@@ -0,0 +1,26 @@
package com.youlai.boot.framework.web.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。
*
* @author Ray.Hao
* @since 4.4.0
*/
@Getter
public class RateLimitException extends RuntimeException {
private final ResultCode resultCode;
public RateLimitException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}
public RateLimitException(ResultCode resultCode, String message) {
super(message);
this.resultCode = resultCode;
}
}

View File

@@ -30,7 +30,7 @@ import java.io.IOException;
*
* <h3>限流维度</h3>
* <pre>{@code
* Key: rate_limiter:ip:{clientIp}
* Key: rate_limit:ip:{clientIp}
* 默认: 1000 req / 60s可通过 rate-limit.ip.* 配置)
* }</pre>
*
@@ -67,11 +67,17 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
}
String ip = IPUtils.getIpAddr(request);
String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
long windowMs = ipConfig.getWindowSeconds() * 1000L;
String key = StrUtil.format(RedisConstants.RateLimit.IP, ip);
long windowMs = ipConfig.getWindow().toMillis();
// 执行滑动窗口计数Lua 原子操作)
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
// 执行滑动窗口计数Lua 原子操作)Redis 异常时 Fail-Open 放行
Long count;
try {
count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
} catch (Exception e) {
log.warn("IP 限流 Redis 异常Fail-Open 放行 ip={}", ip, e);
count = null;
}
int limit = ipConfig.getLimit();
int current = count != null ? count.intValue() : 0;
@@ -80,7 +86,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
if (current > limit) {
log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit);
response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds()));
response.setHeader("Retry-After", String.valueOf(ipConfig.getWindow().getSeconds()));
ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
return;
}

View File

@@ -49,7 +49,7 @@ public final class SlidingWindowScript {
* 执行滑动窗口计数
*
* @param redisTemplate Redis 模板
* @param key 限流 Key如 {@code rate_limiter:ip:192.168.1.1}
* @param key 限流 Key如 {@code rate_limit:ip:192.168.1.1}
* @param windowMs 窗口大小(毫秒)
* @return 当前窗口内请求数(包含本次)
*/

View File

@@ -57,6 +57,7 @@ public final class ResponseWriter {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value();
case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.message.controller;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.SysUserDetails;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.message.service.SseService;
import io.swagger.v3.oas.annotations.Operation;
@@ -29,7 +29,7 @@ public class SseController {
@Operation(summary = "建立SSE连接")
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect() {
SysUserDetails user = SecurityUtils.getUser().orElse(null);
SecurityUserDetails user = SecurityUtils.getUser().orElse(null);
if (user == null) {
log.warn("SSE连接失败未获取到当前用户");
return null;

View File

@@ -5,7 +5,7 @@ import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.PageResult;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.model.form.ConfigForm;
import com.youlai.boot.system.model.query.ConfigQuery;
import com.youlai.boot.system.model.vo.ConfigVO;
@@ -53,11 +53,11 @@ public class ConfigController {
}
@Operation(summary = "获取系统配置表单数据")
@GetMapping("/{id}/form")
@GetMapping("/{configId}/form")
public Result<ConfigForm> getConfigForm(
@Parameter(description = "系统配置ID") @PathVariable Long id
@Parameter(description = "系统配置ID") @PathVariable Long configId
) {
ConfigForm formData = configService.getConfigFormData(id);
ConfigForm formData = configService.getConfigFormData(configId);
return Result.success(formData);
}
@@ -70,19 +70,19 @@ public class ConfigController {
}
@Operation(summary = "修改系统配置")
@PutMapping(value = "/{id}")
@PutMapping(value = "/{configId}")
@PreAuthorize("@ss.hasPerm('sys:config:update')")
@Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.UPDATE)
public Result<?> update(@Valid @PathVariable Long id, @RequestBody ConfigForm configForm) {
return Result.judge(configService.edit(id, configForm));
public Result<?> update(@Valid @PathVariable Long configId, @RequestBody ConfigForm configForm) {
return Result.judge(configService.edit(configId, configForm));
}
@Operation(summary = "删除系统配置")
@DeleteMapping("/{id}")
@DeleteMapping("/{configId}")
@PreAuthorize("@ss.hasPerm('sys:config:delete')")
@Log(module = LogModuleEnum.CONFIG, value = ActionTypeEnum.DELETE)
public Result<?> delete(@PathVariable Long id) {
return Result.judge(configService.delete(id));
public Result<?> delete(@PathVariable Long configId) {
return Result.judge(configService.delete(configId));
}
}

View File

@@ -2,13 +2,13 @@ package com.youlai.boot.system.controller;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.annotation.RepeatSubmit;
import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.system.model.form.DeptForm;
import com.youlai.boot.system.model.query.DeptQuery;
import com.youlai.boot.system.model.vo.DeptVO;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.service.DeptService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

View File

@@ -12,9 +12,9 @@ import com.youlai.boot.system.model.query.DictQuery;
import com.youlai.boot.system.model.vo.DictItemOptionVO;
import com.youlai.boot.system.model.vo.DictItemPageVO;
import com.youlai.boot.system.model.vo.DictPageVO;
import com.youlai.boot.common.annotation.RepeatSubmit;
import com.youlai.boot.framework.annotation.RepeatSubmit;
import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService;
import com.youlai.boot.message.service.SseService;
@@ -67,11 +67,11 @@ public class DictController {
}
@Operation(summary = "获取字典表单数据")
@GetMapping("/{id}/form")
@GetMapping("/{dictId}/form")
public Result<DictForm> getDictForm(
@Parameter(description = "字典ID") @PathVariable Long id
@Parameter(description = "字典ID") @PathVariable Long dictId
) {
DictForm formData = dictService.getDictForm(id);
DictForm formData = dictService.getDictForm(dictId);
return Result.success(formData);
}
@@ -90,14 +90,14 @@ public class DictController {
}
@Operation(summary = "修改字典")
@PutMapping("/{id}")
@PutMapping("/{dictId}")
@PreAuthorize("@ss.hasPerm('sys:dict:update')")
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE)
public Result<?> updateDict(
@PathVariable Long id,
@PathVariable Long dictId,
@RequestBody DictForm dictForm
) {
boolean status = dictService.updateDict(id, dictForm);
boolean status = dictService.updateDict(dictId, dictForm);
// 发送字典更新通知
if (status && dictForm.getDictCode() != null) {
sseService.sendDictChange(dictForm.getDictCode());

Some files were not shown because too many files have changed in this diff Show More