refactor(pigx-common-security): 统一验证码校验逻辑并新增短信验证码前置校验

This commit is contained in:
PIGCLOUD committed 2026-05-20 23:24:23 +08:00
1 parent c796af6c5f
commit 5ad4dfb711
21 files changed
+564 -401

No files matched your search

@@ -0,0 +1,45 @@
package com.pig4cloud.pigx.common.security.captcha;
import com.anji.captcha.service.CaptchaService;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
/**
* 验证码自动配置。仅在业务服务显式引入验证码依赖后注册验证码校验器。
*
* @author lengleng
* @date 2026-05-20
*/
@AutoConfigureAfter(name = "com.pig4cloud.pigx.common.data.cache.RedisTemplateConfiguration")
@ConditionalOnClass(name = {"com.anji.captcha.service.CaptchaService", "com.anji.captcha.model.vo.CaptchaVO"})
@ConditionalOnBean(RedisTemplate.class)
@Configuration(proxyBeanMethods = false)
public class CaptchaAutoConfiguration {
/**
* 验证码服务配置。仅在 anji-captcha 类型存在后再解析相关方法签名。
*/
@Configuration(proxyBeanMethods = false)
static class CaptchaServiceConfiguration {
/**
* 注册验证码统一校验器。
*
* @param captchaService anji-captcha 校验服务
* @param redisTemplate Redis 缓存操作模板
* @return 验证码统一校验器
*/
@Bean
@ConditionalOnMissingBean
CaptchaValidator captchaValidator(CaptchaService captchaService, RedisTemplate<String, Object> redisTemplate) {
return new CaptchaValidator(captchaService, redisTemplate);
}
}
}
@@ -0,0 +1,56 @@
package com.pig4cloud.pigx.common.security.captcha;
import com.anji.captcha.service.CaptchaCacheService;
import com.pig4cloud.pigx.common.core.util.SpringContextHolder;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* 行为验证码缓存的 Redis 实现,通过 SPI 加载替换 anji-captcha 默认本地实现,
* 让多实例部署共享同一份验证码缓存。
*
* @author lengleng
* @date 2026-05-20
*/
public class CaptchaCacheServiceProvider implements CaptchaCacheService {
private static final String REDIS = "redis";
@Override
public void set(String key, String value, long expiresInSeconds) {
redisTemplate().opsForValue().set(key, value, expiresInSeconds, TimeUnit.SECONDS);
}
@Override
public boolean exists(String key) {
return Boolean.TRUE.equals(redisTemplate().hasKey(key));
}
@Override
public void delete(String key) {
redisTemplate().delete(key);
}
@Override
public String get(String key) {
Object value = redisTemplate().opsForValue().get(key);
return value == null ? null : value.toString();
}
@Override
public String type() {
return REDIS;
}
@Override
public Long increment(String key, long val) {
return redisTemplate().opsForValue().increment(key, val);
}
@SuppressWarnings("unchecked")
private RedisTemplate<String, Object> redisTemplate() {
return SpringContextHolder.getBean(RedisTemplate.class);
}
}
@@ -0,0 +1,46 @@
package com.pig4cloud.pigx.common.security.captcha;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 验证码校验结果。失败时携带提示文案,由上层决定包装成异常或 {@code R.failed}。
*
* @author lengleng
* @date 2026-05-20
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CaptchaResult {
/**
* 校验是否通过
*/
private final boolean ok;
/**
* 失败时的提示文案,校验成功时为空
*/
private final String errorMessage;
/**
* 构造验证码校验成功结果。
*
* @return 成功结果,错误提示为空
*/
public static CaptchaResult ok() {
return new CaptchaResult(true, null);
}
/**
* 构造验证码校验失败结果。
*
* @param errorMessage 失败提示文案
* @return 失败结果
*/
public static CaptchaResult failed(String errorMessage) {
return new CaptchaResult(false, errorMessage);
}
}
@@ -0,0 +1,82 @@
package com.pig4cloud.pigx.common.security.captcha;
import cn.hutool.core.util.StrUtil;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
/**
* 行为/图形验证码统一校验入口,支持 blockPuzzle、clickWord、math 三种类型。
*
* <p>
* 调用方传入 {@code captchaType} 与 {@code captchaVerification},
* math 类型的 {@code captchaVerification} 约定为 {@code randomStr---code},
* 与前端组件 {@code SmsCodeVerifyButton} / {@code SmsCodeButton} 中的拼装格式一致。
* </p>
*
* @author lengleng
* @date 2026-05-20
*/
@RequiredArgsConstructor
public class CaptchaValidator {
private final CaptchaService captchaService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 校验验证码。
*
* @param captchaType 验证码类型,取值 blockPuzzle / clickWord / math
* @param captchaVerification 验证码凭据;math 类型为 randomStr---code
* @return 校验结果,失败时携带提示文案
*/
public CaptchaResult validate(String captchaType, String captchaVerification) {
if (StrUtil.hasBlank(captchaType, captchaVerification)) {
return CaptchaResult.failed("验证码参数不能为空");
}
if (StrUtil.equalsAnyIgnoreCase(captchaType, CommonConstants.IMAGE_CODE_BLOCK_PUZZLE,
CommonConstants.IMAGE_CODE_CLICK_WORD)) {
CaptchaVO vo = new CaptchaVO();
vo.setCaptchaType(captchaType);
vo.setCaptchaVerification(captchaVerification);
return captchaService.verification(vo).isSuccess() ? CaptchaResult.ok()
: CaptchaResult.failed("验证码错误或已过期");
}
if (StrUtil.equalsIgnoreCase(captchaType, CommonConstants.IMAGE_CODE_MATH)) {
return validateMath(captchaVerification);
}
return CaptchaResult.failed("验证码类型不支持");
}
private CaptchaResult validateMath(String captchaVerification) {
String separator = CommonConstants.CAPTCHA_VERIFICATION_SEPARATOR;
int separatorIndex = captchaVerification.indexOf(separator);
if (separatorIndex < 0) {
return CaptchaResult.failed("验证码参数不能为空");
}
String randomStr = captchaVerification.substring(0, separatorIndex);
String code = captchaVerification.substring(separatorIndex + separator.length());
if (StrUtil.hasBlank(randomStr, code)) {
return CaptchaResult.failed("验证码参数不能为空");
}
String key = CacheConstants.DEFAULT_CODE_KEY + randomStr;
Object codeObj = redisTemplate.opsForValue().get(key);
if (codeObj == null || StrUtil.isBlank(codeObj.toString()) || !StrUtil.equals(codeObj.toString(), code)) {
redisTemplate.delete(key);
return CaptchaResult.failed("验证码错误或已过期");
}
redisTemplate.delete(key);
return CaptchaResult.ok();
}
}
@@ -0,0 +1,17 @@
/**
* PIGX 验证码安全能力模块。
*
* <p>
* 该包集中封装图形验证码与行为验证码的校验入口,统一处理 blockPuzzle、clickWord、math
* 等验证码类型,避免认证服务、用户中心等业务模块重复拼装校验逻辑。
* </p>
*
* <p>
* 同时提供 anji-captcha 的 Redis 缓存 SPI 实现,让行为验证码在多实例部署时共享缓存数据,
* 保持验证码生成、校验和过期删除的一致性。
* </p>
*
* @author lengleng
* @date 2026-05-20
*/
package com.pig4cloud.pigx.common.security.captcha;
@@ -37,54 +37,50 @@ import java.util.List;
*/
public class PermissionService {
/**
* 判断接口是否有任意xxx,xxx权限
*
* @param permissions 权限
* @return {boolean}
*/
public boolean hasPermission(String... permissions) {
if (ArrayUtil.isEmpty(permissions)) {
return false;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
/**
* 判断接口是否有任意xxx,xxx权限
* @param permissions 权限
* @return {boolean}
*/
public boolean hasPermission(String... permissions) {
if (ArrayUtil.isEmpty(permissions)) {
return false;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
return authorities.stream()
.map(GrantedAuthority::getAuthority)
.filter(StringUtils::hasText)
.anyMatch(x -> PatternMatchUtils.simpleMatch(permissions, x));
}
if (authentication == null) {
return false;
}
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
return authorities.stream()
.map(GrantedAuthority::getAuthority)
.filter(StringUtils::hasText)
.anyMatch(x -> PatternMatchUtils.simpleMatch(permissions, x));
}
/**
* 客户端模式是否有该接口权限
*
* @param scopes scope 列表
* @return {boolean}
* @PreAuthorize("@pms.hasScope('server')")
*/
public boolean hasScope(String... scopes) {
if (ArrayUtil.isEmpty(scopes)) {
return false;
}
/**
* 客户端模式是否有该接口权限
* @param scopes scope 列表
* @return {boolean} @PreAuthorize("@pms.hasScope('server')")
*/
public boolean hasScope(String... scopes) {
if (ArrayUtil.isEmpty(scopes)) {
return false;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
if (authentication == null) {
return false;
}
if (authentication instanceof BearerTokenAuthentication) {
BearerTokenAuthentication bearerTokenAuthentication = (BearerTokenAuthentication) authentication;
List<String> scopeList = MapUtil.get(bearerTokenAuthentication.getTokenAttributes()
, OAuth2ParameterNames.SCOPE, List.class, new ArrayList<>());
return scopeList.stream().anyMatch(x -> PatternMatchUtils.simpleMatch(scopes, x));
}
return false;
}
if (authentication instanceof BearerTokenAuthentication) {
BearerTokenAuthentication bearerTokenAuthentication = (BearerTokenAuthentication) authentication;
List<String> scopeList = MapUtil.get(bearerTokenAuthentication.getTokenAttributes(),
OAuth2ParameterNames.SCOPE, List.class, new ArrayList<>());
return scopeList.stream().anyMatch(x -> PatternMatchUtils.simpleMatch(scopes, x));
}
return false;
}
}
@@ -1,11 +1,20 @@
package com.pig4cloud.pigx.common.security.component;
import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pigx.admin.api.entity.SysApiKey;
import com.pig4cloud.pigx.admin.api.feign.RemoteApiKeyService;
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.core.util.SpringContextHolder;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import com.pig4cloud.pigx.common.security.service.PigxDefaultUserDetailsServiceImpl;
import com.pig4cloud.pigx.common.security.service.PigxUser;
import com.pig4cloud.pigx.common.security.service.PigxUserDetailsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.core.Ordered;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
@@ -20,10 +29,9 @@ import org.springframework.security.oauth2.server.resource.InvalidBearerTokenExc
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
import java.security.Principal;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
/**
* @author lengleng
@@ -37,6 +45,11 @@ public class PigxCustomOpaqueTokenIntrospector implements OpaqueTokenIntrospecto
@Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
// API-KEY 前缀检测:sk- 开头走独立处理逻辑
if (token.startsWith("sk-")) {
return handleApiKeyToken(token);
}
OAuth2Authorization oldAuthorization = authorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);
if (Objects.isNull(oldAuthorization)) {
throw new InvalidBearerTokenException(token);
@@ -96,4 +109,93 @@ public class PigxCustomOpaqueTokenIntrospector implements OpaqueTokenIntrospecto
return pigxUser;
}
/**
* 处理 API-KEY 类型的 token
* 1. 计算 SHA-256 哈希
* 2. 查询 API-KEY 记录
* 3. 校验状态、过期时间、IP白名单
* 4. 加载用户信息并返回
*/
private OAuth2AuthenticatedPrincipal handleApiKeyToken(String token) {
String hash = sha256(token);
// 优先从缓存获取,避免远程调用
CacheManager cacheManager = SpringContextHolder.getBean(CacheManager.class);
Cache cache = cacheManager.getCache(CacheConstants.API_KEY_DETAILS);
RemoteApiKeyService remoteApiKeyService = SpringContextHolder.getBean(RemoteApiKeyService.class);
SysApiKey apiKey = null;
if (cache != null && cache.get(hash) != null) {
apiKey = cache.get(hash, SysApiKey.class);
}
if (apiKey == null) {
R<SysApiKey> result = remoteApiKeyService.getByHash(hash);
if (result == null || result.getData() == null) {
throw new InvalidBearerTokenException("Invalid API Key");
}
apiKey = result.getData();
// 回填缓存
if (cache != null) {
cache.put(hash, apiKey);
}
}
// 校验过期时间
if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(LocalDateTime.now())) {
throw new InvalidBearerTokenException("API Key has expired");
}
// 校验 IP 白名单
if (apiKey.getAllowedIps() != null && !apiKey.getAllowedIps().isEmpty()) {
String requestIp = WebUtils.getIP();
if (requestIp == null || !isIpAllowed(requestIp, apiKey.getAllowedIps())) {
throw new InvalidBearerTokenException("IP not allowed for this API Key");
}
}
// 通过 apiKey 中的 username 直接加载用户详情,无需二次远程调用
UserDetails userDetails = SpringContextHolder.getBean(PigxDefaultUserDetailsServiceImpl.class).loadUserByUsername(apiKey.getUsername());
if (!(userDetails instanceof PigxUser pigxUser)) {
throw new InvalidBearerTokenException("API Key owner not found");
}
if (!pigxUser.isAccountNonLocked()) {
log.warn("用户账号 {} 已被锁定,拒绝 API Key 访问", pigxUser.getUsername());
throw new InvalidBearerTokenException("Account is locked");
}
if (!pigxUser.isAccountNonExpired()) {
log.warn("用户账号 {} 已过期,拒绝 API Key 访问", pigxUser.getUsername());
throw new InvalidBearerTokenException("Account has expired");
}
// 异步更新最后使用时间,不阻塞主流程
final Long apiKeyId = apiKey.getId();
CompletableFuture.runAsync(() -> {
try {
remoteApiKeyService.updateLastUsed(apiKeyId);
} catch (Exception ex) {
log.warn("Failed to update API Key last_used_at: {}", ex.getMessage());
}
});
pigxUser.getAttributes().put(SecurityConstants.CLIENT_ID, "apikey");
return pigxUser;
}
private boolean isIpAllowed(String requestIp, String allowedIps) {
return Arrays.stream(allowedIps.split(StrUtil.COMMA))
.map(String::trim)
.anyMatch(requestIp::equals);
}
private static String sha256(String input) {
try {
byte[] hash = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}
@@ -17,7 +17,10 @@
package com.pig4cloud.pigx.common.security.component;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.common.core.context.UserContextHolder;
import com.pig4cloud.pigx.common.security.context.PigxSecurityUserContextHolder;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
@@ -73,4 +76,14 @@ public class PigxResourceServerAutoConfiguration {
return new PigxCustomOpaqueTokenIntrospector(authorizationService);
}
/**
* 通用用户上下文 SPI 的安全实现,供 data / audit 等模块按接口注入使用。
* @return UserContextHolder
*/
@Bean
@ConditionalOnMissingBean(UserContextHolder.class)
public UserContextHolder userContextHolder() {
return new PigxSecurityUserContextHolder();
}
}
@@ -22,7 +22,6 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.authorization.method.PrePostTemplateDefaults;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -95,7 +94,7 @@ public class PigxResourceServerConfiguration {
/**
* 创建并返回一个支持自定义权限表达式的默认模板实例
*
* @return {@link PrePostTemplateDefaults} 权限表达式默认模板实例
* @return {@link AnnotationTemplateExpressionDefaults} 权限表达式默认模板实例
*/
@Bean
AnnotationTemplateExpressionDefaults prePostTemplateDefaults() {
@@ -29,6 +29,10 @@ import org.aspectj.lang.annotation.Before;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import java.util.Objects;
/**
* @author lengleng
@@ -46,6 +50,11 @@ public class PigxSecurityInnerAspect implements Ordered {
@SneakyThrows
@Before("@within(inner) || @annotation(inner)")
public void around(JoinPoint point, Inner inner) {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (Objects.isNull(attrs)) {
return;
}
String header = request.getHeader(SecurityConstants.FROM);
if (inner == null) {
Class<?> clazz = point.getTarget().getClass();
@@ -20,6 +20,8 @@ import cn.hutool.http.ContentType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
import com.pig4cloud.pigx.common.core.util.R;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.context.MessageSource;
@@ -27,49 +29,49 @@ import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
/**
* @author lengleng
* @date 2019/2/1
*
* <p>
* 客户端异常处理 AuthenticationException 不同细化异常处理
*/
@RequiredArgsConstructor
public class ResourceAuthExceptionEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
private final ObjectMapper objectMapper;
private final MessageSource messageSource;
private final MessageSource messageSource;
@Override
@SneakyThrows
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
response.setCharacterEncoding(CommonConstants.UTF8);
response.setContentType(ContentType.JSON.getValue());
R<String> result = new R<>();
result.setCode(CommonConstants.FAIL);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
if (authException != null) {
result.setMsg("error");
result.setData(authException.getMessage());
}
@Override
@SneakyThrows
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
response.setCharacterEncoding(CommonConstants.UTF8);
response.setContentType(ContentType.JSON.getValue());
R<String> result = new R<>();
result.setCode(CommonConstants.FAIL);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
if (authException != null) {
result.setMsg("error");
result.setData(authException.getMessage());
}
// 针对令牌过期返回特殊的 424
if (authException instanceof InvalidBearerTokenException
|| authException instanceof InsufficientAuthenticationException) {
response.setStatus(HttpStatus.FAILED_DEPENDENCY.value());
result.setMsg(this.messageSource.getMessage("OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired",
null, LocaleContextHolder.getLocale()));
}
PrintWriter printWriter = response.getWriter();
printWriter.append(objectMapper.writeValueAsString(result));
}
// 针对令牌过期返回特殊的 424
if (authException instanceof InvalidBearerTokenException
|| authException instanceof UsernameNotFoundException
|| authException instanceof InsufficientAuthenticationException) {
response.setStatus(HttpStatus.FAILED_DEPENDENCY.value());
result.setMsg(this.messageSource.getMessage("OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired",
null, LocaleContextHolder.getLocale()));
}
PrintWriter printWriter = response.getWriter();
printWriter.append(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: lengleng (wangiegie@gmail.com)
*/
package com.pig4cloud.pigx.common.security.context;
import com.pig4cloud.pigx.common.core.context.UserContext;
import com.pig4cloud.pigx.common.core.context.UserContextHolder;
import com.pig4cloud.pigx.common.security.service.PigxUser;
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
/**
* 基于 Spring Security 上下文的 {@link UserContextHolder} 实现。
*
* @author lengleng
*/
public class PigxSecurityUserContextHolder implements UserContextHolder {
@Override
public UserContext get() {
PigxUser user = SecurityUtils.getUser();
if (user == null) {
return null;
}
return new PigxUserContextAdapter(user);
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2018-2025, lengleng All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* Neither the name of the pig4cloud.com developer nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
* Author: lengleng (wangiegie@gmail.com)
*/
package com.pig4cloud.pigx.common.security.context;
import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.context.UserContext;
import com.pig4cloud.pigx.common.security.service.PigxUser;
import org.springframework.security.core.GrantedAuthority;
import java.util.List;
import java.util.Objects;
/**
* 将 {@link PigxUser} 适配为通用 {@link UserContext},
* 从 authorities 中按 {@link SecurityConstants#ROLE} 前缀解析 roleIds,
* 与改造前 {@code PigxDefaultDataScopeHandle} 的行为保持一致。
*
* @author lengleng
*/
public class PigxUserContextAdapter implements UserContext {
private final PigxUser delegate;
public PigxUserContextAdapter(PigxUser delegate) {
this.delegate = delegate;
}
@Override
public String getUsername() {
return delegate.getUsername();
}
@Override
public Long getDeptId() {
return delegate.getDeptId();
}
@Override
public List<Long> getRoleIds() {
return delegate.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.filter(authority -> StrUtil.startWith(authority, SecurityConstants.ROLE))
.map(authority -> Long.parseLong(Objects.requireNonNull(StrUtil.removePrefix(authority, SecurityConstants.ROLE))))
.toList();
}
}
@@ -1,34 +0,0 @@
package com.pig4cloud.pigx.common.security.feign;
import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
/**
* TOC 客户标识传递
*
* @author lengleng
* @date 2023/3/17
*/
@Slf4j
public class PigxClientToCRequestInterceptor implements RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied
* {@link RequestTemplate}.
* @param template
*/
public void apply(RequestTemplate template) {
String reqVersion = WebUtils.getRequest() != null
? WebUtils.getRequest().getHeader(SecurityConstants.HEADER_TOC) : null;
if (StrUtil.isNotBlank(reqVersion)) {
log.debug("feign add header toc :{}", reqVersion);
template.header(SecurityConstants.HEADER_TOC, reqVersion);
}
}
}
@@ -32,9 +32,4 @@ public class PigxFeignClientConfiguration {
return new PigxOAuthRequestInterceptor(tokenResolver);
}
@Bean
public RequestInterceptor clientToCRequestInterceptor() {
return new PigxClientToCRequestInterceptor();
}
}
@@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import com.pig4cloud.pigx.common.security.util.NonWebTokenContextHolder;
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
@@ -56,6 +57,11 @@ public class PigxOAuthRequestInterceptor implements RequestInterceptor {
token = tokenResolver.resolve(request);
}
// 从webcontext 中获取 token
if (StrUtil.isBlank(token)) {
token = SecurityUtils.getToken();
}
// 如果 request 中没有 token,从 NonWebTokenContextHolder 获取(适配消息队列等非 Web 场景)
if (StrUtil.isBlank(token)) {
token = NonWebTokenContextHolder.getToken();
@@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
public class PigxRedisOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final static Long TIMEOUT = 10L;
@@ -19,23 +19,23 @@ public class PigxRedisOAuth2AuthorizationConsentService implements OAuth2Authori
public void save(OAuth2AuthorizationConsent authorizationConsent) {
Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
redisTemplate.opsForValue()
.set(buildKey(authorizationConsent), authorizationConsent, TIMEOUT, TimeUnit.MINUTES);
redisTemplate.opsForValue()
.set(buildKey(authorizationConsent), authorizationConsent, TIMEOUT, TimeUnit.MINUTES);
}
@Override
public void remove(OAuth2AuthorizationConsent authorizationConsent) {
Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
redisTemplate.delete(buildKey(authorizationConsent));
redisTemplate.delete(buildKey(authorizationConsent));
}
@Override
public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) {
Assert.hasText(registeredClientId, "registeredClientId cannot be empty");
Assert.hasText(principalName, "principalName cannot be empty");
return (OAuth2AuthorizationConsent) redisTemplate.opsForValue()
.get(buildKey(registeredClientId, principalName));
return (OAuth2AuthorizationConsent) redisTemplate.opsForValue()
.get(buildKey(registeredClientId, principalName));
}
private static String buildKey(String registeredClientId, String principalName) {
@@ -1,142 +0,0 @@
/*
* Copyright (c) 2020 pig4cloud Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.pig4cloud.pigx.common.security.service;
import cn.hutool.core.util.ArrayUtil;
import com.pig4cloud.pigx.app.api.dto.AppUserInfo;
import com.pig4cloud.pigx.app.api.entity.AppUser;
import com.pig4cloud.pigx.app.api.feign.RemoteAppUserService;
import com.pig4cloud.pigx.common.core.constant.CacheConstants;
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.constant.enums.UserTypeEnum;
import com.pig4cloud.pigx.common.core.util.MsgUtils;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.core.util.RetOps;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import java.util.*;
/**
* 用户详细信息
*
* @author lengleng hccake
*/
@Slf4j
@RequiredArgsConstructor
public class PigxTocDefaultUserDetailsServiceImpl implements PigxUserDetailsService {
private final CacheManager cacheManager;
private final Optional<RemoteAppUserService> remoteAppUserServiceOptional;
/**
* 用户密码登录
*
* @param username 用户密码登录
* @return
*/
@Override
@SneakyThrows
public UserDetails loadUserByUsername(String username) {
Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS_MINI);
if (Objects.nonNull(cache) && Objects.nonNull(cache.get(username))) {
AppUserInfo appUserInfo = cache.get(username, AppUserInfo.class);
return getUserDetailsAppUser(R.ok(appUserInfo));
}
R<AppUserInfo> result = remoteAppUserServiceOptional.get().info(username);
Optional<AppUserInfo> userInfoOptional = RetOps.of(result).getData();
userInfoOptional.ifPresent(userInfo -> Objects.requireNonNull(cache).put(username, userInfo));
return getUserDetailsAppUser(result);
}
@Override
public UserDetails loadUserByUser(PigxUser pigxUser) {
return pigxUser;
}
UserDetails getUserDetailsAppUser(R<AppUserInfo> result) {
// @formatter:off
return RetOps.of(result)
.assertSuccess(r -> new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, MsgUtils.getSecurityMessage(NOTFOUND_USER_ERROR_CODE),null)))
.getData()
.map(this::convertUserDetailsAppUser)
.orElseThrow(() -> new UsernameNotFoundException(MsgUtils.getSecurityMessage(NOTFOUND_USER_ERROR_CODE)));
// @formatter:on
}
/**
* UserInfo 转 UserDetails
*
* @param info
* @return 返回UserDetails对象
*/
UserDetails convertUserDetailsAppUser(AppUserInfo info) {
Set<String> dbAuthsSet = new HashSet<>();
if (ArrayUtil.isNotEmpty(info.getRoles())) {
// 获取角色
Arrays.stream(info.getRoles()).forEach(roleId -> dbAuthsSet.add(SecurityConstants.ROLE + roleId));
// 获取资源
dbAuthsSet.addAll(Arrays.asList(info.getPermissions()));
}
Collection<? extends GrantedAuthority> authorities = AuthorityUtils
.createAuthorityList(dbAuthsSet.toArray(new String[0]));
AppUser user = info.getAppUser();
// 构造security用户
return new PigxUser(user.getUserId(), user.getUsername(), null, null, null, null, user.getPhone(), user.getAvatar(),
user.getNickname(), user.getName(), user.getEmail(), user.getTenantId(),
SecurityConstants.BCRYPT + user.getPassword(), true, true, UserTypeEnum.TOC.getStatus(), true, null,
!CommonConstants.STATUS_LOCK.equals(user.getLockFlag()), authorities);
}
@Override
public int getOrder() {
return 10;
}
/**
* 支持所有的 mobile 类型
*
* @param clientId 目标客户端
* @param grantType 授权类型
* @return true/false
*/
@Override
public boolean support(String clientId, String grantType) {
if (Objects.isNull(WebUtils.getRequest())) {
return false;
}
String header = WebUtils.getRequest().getHeader(SecurityConstants.HEADER_TOC);
return SecurityConstants.HEADER_TOC_YES.equals(header);
}
}
@@ -1,133 +0,0 @@
/*
* Copyright (c) 2020 pig4cloud Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.pig4cloud.pigx.common.security.service;
import cn.hutool.core.util.ArrayUtil;
import com.pig4cloud.pigx.app.api.dto.AppUserInfo;
import com.pig4cloud.pigx.app.api.entity.AppUser;
import com.pig4cloud.pigx.app.api.feign.RemoteAppUserService;
import com.pig4cloud.pigx.common.core.constant.CommonConstants;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.constant.enums.UserTypeEnum;
import com.pig4cloud.pigx.common.core.util.MsgUtils;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.core.util.RetOps;
import com.pig4cloud.pigx.common.core.util.WebUtils;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import java.util.*;
/**
* 用户详细信息
*
* @author lengleng hccake
*/
@Slf4j
@RequiredArgsConstructor
public class PigxTocMobileUserDetailsServiceImpl implements PigxUserDetailsService {
private final Optional<RemoteAppUserService> remoteAppUserServiceOptional;
/**
* 用户密码登录
*
* @param phone 用户密码登录
* @return
*/
@Override
@SneakyThrows
public UserDetails loadUserByUsername(String phone) {
if (!remoteAppUserServiceOptional.isPresent()) {
throw new UnsupportedOperationException();
}
R<AppUserInfo> info = remoteAppUserServiceOptional.get().social(phone);
return this.getUserDetailsAppUser(info);
}
@Override
public UserDetails loadUserByUser(PigxUser pigxUser) {
return pigxUser;
}
UserDetails getUserDetailsAppUser(R<AppUserInfo> result) {
// @formatter:off
return RetOps.of(result)
.assertSuccess(r -> new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,MsgUtils.getSecurityMessage(NOTFOUND_USER_ERROR_CODE),null)))
.getData()
.map(this::convertUserDetailsAppUser)
.orElseThrow(() -> new UsernameNotFoundException(MsgUtils.getSecurityMessage(NOTFOUND_USER_ERROR_CODE)));
// @formatter:on
}
/**
* UserInfo 转 UserDetails
*
* @param info
* @return 返回UserDetails对象
*/
UserDetails convertUserDetailsAppUser(AppUserInfo info) {
Set<String> dbAuthsSet = new HashSet<>();
if (ArrayUtil.isNotEmpty(info.getRoles())) {
// 获取角色
Arrays.stream(info.getRoles()).forEach(roleId -> dbAuthsSet.add(SecurityConstants.ROLE + roleId));
// 获取资源
dbAuthsSet.addAll(Arrays.asList(info.getPermissions()));
}
Collection<? extends GrantedAuthority> authorities = AuthorityUtils
.createAuthorityList(dbAuthsSet.toArray(new String[0]));
AppUser user = info.getAppUser();
// 构造security用户
return new PigxUser(user.getUserId(), user.getUsername(), null, null, null, null, user.getPhone(), user.getAvatar(),
user.getNickname(), user.getName(), user.getEmail(), user.getTenantId(),
SecurityConstants.BCRYPT + user.getPassword(), true, true, UserTypeEnum.TOC.getStatus(), true, null,
!CommonConstants.STATUS_LOCK.equals(user.getLockFlag()), authorities);
}
@Override
public int getOrder() {
return 15;
}
/**
* 支持所有的 mobile 类型
*
* @param clientId 目标客户端
* @param grantType 授权类型
* @return true/false
*/
@Override
public boolean support(String clientId, String grantType) {
if (Objects.isNull(WebUtils.getRequest())) {
return false;
}
String header = WebUtils.getRequest().getHeader(SecurityConstants.HEADER_TOC);
return SecurityConstants.HEADER_TOC_YES.equals(header) && SecurityConstants.GRANT_MOBILE.equals(grantType);
}
}
@@ -0,0 +1 @@
com.pig4cloud.pigx.common.security.captcha.CaptchaCacheServiceProvider
@@ -1,7 +1,5 @@
com.pig4cloud.pigx.common.security.service.PigxDefaultUserDetailsServiceImpl
com.pig4cloud.pigx.common.security.service.PigxTocDefaultUserDetailsServiceImpl
com.pig4cloud.pigx.common.security.service.PigxMobileUserDetailServiceImpl
com.pig4cloud.pigx.common.security.service.PigxTocMobileUserDetailsServiceImpl
com.pig4cloud.pigx.common.security.service.PigxRedisOAuth2AuthorizationService
com.pig4cloud.pigx.common.security.service.PigxRedisOAuth2AuthorizationConsentService
com.pig4cloud.pigx.common.security.component.PigxBootCorsProperties
@@ -9,4 +7,4 @@ com.pig4cloud.pigx.common.security.component.PigxSecurityInnerAspect
com.pig4cloud.pigx.common.security.component.PigxInnerHeaderRemoveFilter
com.pig4cloud.pigx.common.security.component.PigxSecurityMessageSourceConfiguration
com.pig4cloud.pigx.common.security.service.PigxRemoteRegisteredClientRepository
com.pig4cloud.pigx.common.security.captcha.CaptchaAutoConfiguration