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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
// 获取数据权限列表(用于并集策略)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.youlai.boot.auth.qrcode.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 扫码登录票据上下文,序列化为 JSON 存入 Redis。
|
||||
* <p>
|
||||
* 字段说明见 docs/youlai-boot/scan-code-login.md 的 Redis 存储设计。
|
||||
*/
|
||||
@Data
|
||||
public class QrCodeLoginContext {
|
||||
|
||||
/** 票据,UUID 无连字符 */
|
||||
private String ticket;
|
||||
|
||||
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
|
||||
private String status;
|
||||
|
||||
/** 扫码用户 ID,scan 时写入 */
|
||||
private Long userId;
|
||||
|
||||
/** 用户原始昵称(未脱敏),status 接口返回时脱敏 */
|
||||
private String nickname;
|
||||
|
||||
/** 用户头像 URL */
|
||||
private String avatar;
|
||||
|
||||
/** 创建时间戳(毫秒) */
|
||||
private Long createdAt;
|
||||
|
||||
/** 扫码时间戳(毫秒) */
|
||||
private Long scannedAt;
|
||||
|
||||
/** 确认时间戳(毫秒) */
|
||||
private Long confirmedAt;
|
||||
|
||||
/** generate 时的 PC 端 IP,用于审计 */
|
||||
private String clientIp;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.youlai.boot.auth.qrcode.model;
|
||||
|
||||
/**
|
||||
* 扫码登录票据状态。
|
||||
* <p>
|
||||
* WAITING 票据已创建,等待 APP 扫码
|
||||
* SCANNED APP 已扫码,等待用户在手机上确认
|
||||
* CONFIRMED 用户已在 APP 上确认登录
|
||||
* LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
|
||||
* CANCELED 用户在 APP 上取消登录
|
||||
* EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
|
||||
*/
|
||||
public enum QrCodeLoginStatusEnum {
|
||||
|
||||
WAITING,
|
||||
SCANNED,
|
||||
CONFIRMED,
|
||||
LOGGED_IN,
|
||||
CANCELED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.youlai.boot.auth.qrcode.model.form;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
|
||||
*/
|
||||
@Data
|
||||
public class QrCodeTicketForm {
|
||||
|
||||
@NotBlank(message = "票据不能为空")
|
||||
private String ticket;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* generate 接口响应。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码票据生成结果")
|
||||
public class QrCodeGenerateVO {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "有效期(秒)")
|
||||
private Integer expireSeconds;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.youlai.boot.auth.qrcode.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* status/scan/confirm/cancel 接口响应。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@Schema(description = "扫码登录状态")
|
||||
public class QrCodeStatusVO {
|
||||
|
||||
@Schema(description = "票据")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "状态:WAITING/SCANNED/CONFIRMED/LOGGED_IN/CANCELED/EXPIRED")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "脱敏昵称,SCANNED 之后返回")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "头像 URL,SCANNED 之后返回")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "剩余有效期(秒)")
|
||||
private Integer expireSeconds;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.youlai.boot.auth.qrcode.service;
|
||||
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
|
||||
/**
|
||||
* 扫码登录服务。
|
||||
*/
|
||||
public interface QrCodeLoginService {
|
||||
|
||||
/** 生成票据,未登录调用 */
|
||||
QrCodeGenerateVO generate(String clientIp);
|
||||
|
||||
/** 查询状态,未登录调用 */
|
||||
QrCodeStatusVO status(String ticket);
|
||||
|
||||
/** APP 标记已扫码,需 APP 端已登录 */
|
||||
QrCodeStatusVO scan(String ticket, Long userId);
|
||||
|
||||
/** APP 确认登录,需 APP 端已登录 */
|
||||
QrCodeStatusVO confirm(String ticket, Long userId);
|
||||
|
||||
/** APP 取消登录,需 APP 端已登录 */
|
||||
QrCodeStatusVO cancel(String ticket, Long userId);
|
||||
|
||||
/** PC 端用票据换取会话令牌,未登录调用 */
|
||||
AuthenticationToken login(String ticket);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.youlai.boot.auth.qrcode.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
|
||||
import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
|
||||
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
|
||||
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
|
||||
import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.common.result.ResultCode;
|
||||
import com.youlai.boot.framework.security.exception.TokenInvalidException;
|
||||
import com.youlai.boot.framework.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.framework.security.model.SecurityUser;
|
||||
import com.youlai.boot.framework.security.model.SecurityUserDetails;
|
||||
import com.youlai.boot.framework.security.token.TokenManager;
|
||||
import com.youlai.boot.system.service.UserSocialService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 扫码登录服务实现。
|
||||
* <p>
|
||||
* 票据上下文以 JSON 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
|
||||
* {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截,非法迁移直接抛业务异常,
|
||||
* 因此同一 ticket 被多端并发操作时不需要分布式锁——最坏情况是后到的操作在状态校验处失败。
|
||||
* PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)},
|
||||
* 生成的令牌与账号密码登录走的是同一套会话治理(单/多设备、登出、刷新),前端无差别处理。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
|
||||
|
||||
/** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
|
||||
private static final int DEFAULT_EXPIRE_SECONDS = 300;
|
||||
|
||||
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
|
||||
private static final int MIN_REMAIN_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储),
|
||||
* 用它把 Map 转回强类型上下文对象。JsonMapper 线程安全,复用单例。
|
||||
*/
|
||||
private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final TokenManager tokenManager;
|
||||
private final UserSocialService userSocialService;
|
||||
|
||||
@Override
|
||||
public QrCodeGenerateVO generate(String clientIp) {
|
||||
String ticket = IdUtil.fastSimpleUUID();
|
||||
QrCodeLoginContext ctx = new QrCodeLoginContext();
|
||||
ctx.setTicket(ticket);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name());
|
||||
ctx.setCreatedAt(System.currentTimeMillis());
|
||||
ctx.setClientIp(clientIp);
|
||||
save(ctx, DEFAULT_EXPIRE_SECONDS);
|
||||
return QrCodeGenerateVO.builder()
|
||||
.ticket(ticket)
|
||||
.expireSeconds(DEFAULT_EXPIRE_SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO status(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO scan(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
|
||||
fillUserInfo(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
|
||||
ctx.setScannedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO confirm(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
|
||||
requireSameUser(ctx, userId);
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
|
||||
ctx.setConfirmedAt(System.currentTimeMillis());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QrCodeStatusVO cancel(String ticket, Long userId) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
// 只有还在流程中的票据(等待扫码 / 已扫码 / 已确认)允许取消,已登录或已取消的重复操作直接拒绝
|
||||
if (current != QrCodeLoginStatusEnum.WAITING
|
||||
&& current != QrCodeLoginStatusEnum.SCANNED
|
||||
&& current != QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
// 一旦有人扫过码,取消权就归扫码本人,防止他人替扫码用户取消
|
||||
if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
|
||||
requireSameUser(ctx, userId);
|
||||
}
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
|
||||
save(ctx, refreshTtl(ticket));
|
||||
return toVO(ctx, remainingSeconds(ticket));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationToken login(String ticket) {
|
||||
QrCodeLoginContext ctx = loadOrThrow(ticket);
|
||||
requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
|
||||
SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId());
|
||||
if (securityUser == null) {
|
||||
throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
|
||||
}
|
||||
SecurityUserDetails details = new SecurityUserDetails(securityUser);
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
||||
details, null, details.getAuthorities());
|
||||
AuthenticationToken token = tokenManager.generateToken(authentication);
|
||||
// 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒,杜绝重放
|
||||
ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
|
||||
save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
|
||||
return token;
|
||||
}
|
||||
|
||||
// ======================== private ========================
|
||||
|
||||
/** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
|
||||
private QrCodeLoginContext loadOrThrow(String ticket) {
|
||||
if (StrUtil.isBlank(ticket)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
Object raw = redisTemplate.opsForValue().get(key(ticket));
|
||||
if (raw == null) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
if (raw instanceof QrCodeLoginContext ctx) {
|
||||
return ctx;
|
||||
}
|
||||
// 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型
|
||||
try {
|
||||
return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class);
|
||||
} catch (Exception e) {
|
||||
log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e);
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
|
||||
private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
|
||||
if (!expected.name().equals(ctx.getStatus())) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
|
||||
private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
|
||||
if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
|
||||
throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
|
||||
private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
|
||||
SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
|
||||
if (info == null) {
|
||||
throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND);
|
||||
}
|
||||
ctx.setUserId(userId);
|
||||
ctx.setNickname(info.getNickname());
|
||||
ctx.setAvatar(info.getAvatar());
|
||||
}
|
||||
|
||||
/** 重写整个上下文并刷新 TTL */
|
||||
private void save(QrCodeLoginContext ctx, int ttl) {
|
||||
redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
|
||||
private int remainingSeconds(String ticket) {
|
||||
Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
|
||||
return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
|
||||
}
|
||||
|
||||
/** 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 */
|
||||
private int refreshTtl(String ticket) {
|
||||
int remain = remainingSeconds(ticket);
|
||||
return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
|
||||
}
|
||||
|
||||
/** 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */
|
||||
private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
|
||||
QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
|
||||
String nickname = null;
|
||||
String avatar = null;
|
||||
// WAITING 阶段谁都能查状态,此时不能泄露用户信息;扫码/确认后才回传脱敏昵称与头像
|
||||
if (status == QrCodeLoginStatusEnum.SCANNED
|
||||
|| status == QrCodeLoginStatusEnum.CONFIRMED) {
|
||||
nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
|
||||
avatar = ctx.getAvatar();
|
||||
}
|
||||
return QrCodeStatusVO.builder()
|
||||
.ticket(ctx.getTicket())
|
||||
.status(status.name())
|
||||
.nickname(nickname)
|
||||
.avatar(avatar)
|
||||
.expireSeconds(expireSeconds)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 拼接票据在 Redis 中的完整 Key */
|
||||
private String key(String ticket) {
|
||||
return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.youlai.boot.auth.qrcode.util;
|
||||
|
||||
/**
|
||||
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
|
||||
* <p>
|
||||
* 长度 1:原样返回
|
||||
* 长度 2:首字 + *
|
||||
* 长度 ≥3:首字 + (n-2) 个 * + 末字
|
||||
*/
|
||||
public final class QrCodeNicknameMasker {
|
||||
|
||||
private QrCodeNicknameMasker() {
|
||||
}
|
||||
|
||||
public static String mask(String nickname) {
|
||||
if (nickname == null || nickname.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int len = nickname.length();
|
||||
if (len == 1) {
|
||||
return nickname;
|
||||
}
|
||||
if (len == 2) {
|
||||
return nickname.charAt(0) + "*";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(nickname.charAt(0));
|
||||
for (int i = 0; i < len - 2; i++) {
|
||||
sb.append('*');
|
||||
}
|
||||
sb.append(nickname.charAt(len - 1));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -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 模块),安全规则(放行路径、CORS、Provider 装配、响应格式)
|
||||
* 因项目而异,不应由框架层强制装配。
|
||||
*
|
||||
* @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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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){
|
||||
// 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败
|
||||
} else if (authException instanceof InsufficientAuthenticationException) {
|
||||
ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
|
||||
} else {
|
||||
// 其他未明确处理的认证异常(如账户被锁定、账户禁用等)
|
||||
ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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=SecurityUserDetails,credentials=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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SysUserDetails,authorities 为角色/权限集合)。
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
Authentication authentication = authenticationManager.authenticate(authenticationToken);
|
||||
|
||||
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ public interface JwtClaimConstants {
|
||||
String DATA_SCOPES = "dataScopes";
|
||||
|
||||
/**
|
||||
* 权限(角色Code)集合
|
||||
* 角色编码集合(不带 ROLE_ 前缀)
|
||||
*/
|
||||
String AUTHORITIES = "authorities";
|
||||
String ROLES = "roles";
|
||||
|
||||
/**
|
||||
* Token 版本号
|
||||
|
||||
@@ -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 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效
|
||||
String USER_TOKEN_VERSION = "auth:user:token_version:{}";
|
||||
// 扫码登录票据上下文(ticket -> QrCodeLoginContext)
|
||||
String QR_CODE_LOGIN = "auth:qr_code:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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", "访问未授权"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.common.annotation;
|
||||
package com.youlai.boot.framework.annotation;
|
||||
|
||||
|
||||
import java.lang.annotation.*;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,则跳过数据权限过滤(并集策略)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 当前窗口内请求数(包含本次)
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user