generated from youfool-project/youfool-prj-springboot-template
Compare commits
2 Commits
d91c141c6d
...
72ff1c7b38
| Author | SHA1 | Date |
|---|---|---|
|
|
72ff1c7b38 | |
|
|
e603ad7127 |
194
README.md
194
README.md
|
|
@ -1,8 +1,194 @@
|
|||
# youfool-framework-springboot 基础项目模板
|
||||
# CCDemo - Spring Boot 基础项目模板
|
||||
|
||||
## 数据源
|
||||
基于 youfool-framework-springboot 3.3.1 的 Spring Boot 项目模板,集成常用功能和最佳实践。
|
||||
|
||||
当前版本(3.0版本以上)的基础框架使用了`苞米豆`的[动态数据源](https://github.com/baomidou/dynamic-datasource-spring-boot-starter) 。
|
||||
## 项目特性
|
||||
|
||||
使用事务时需注意使用`@DSTransactional`代替`@Transactional`来使用注解式事务。
|
||||
- 🛡️ **安全防护**:集成 XSS 防护、SM3 加密、RSA 加密
|
||||
- 🔐 **认证授权**:基于 Sa-Token 的权限管理
|
||||
- 📊 **API 文档**:集成 Knife4j (Swagger) 接口文档
|
||||
- 📈 **监控日志**:集成 Druid 数据源监控和 REST 日志记录
|
||||
- 🚦 **限流控制**:内置漏桶算法限流器
|
||||
- 🗄️ **数据源**:支持动态数据源切换
|
||||
- 📁 **文件管理**:支持文件上传功能
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**:Spring Boot 2.3.5
|
||||
- **基础框架**:youfool-framework-springboot 3.3.1
|
||||
- **数据库**:PostgreSQL / MySQL
|
||||
- **安全**:Sa-Token、RSA、SM3
|
||||
- **工具**:Lombok、Junit
|
||||
- **文档**:Knife4j (Swagger)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/main/java/io/lroyia/ccdemo/
|
||||
├── annotation/ # 自定义注解
|
||||
│ └── RateLimit.java # 限流注解
|
||||
├── aspect/ # AOP 切面
|
||||
│ └── RateLimiterAspect.java # 限流切面
|
||||
├── config/ # 配置类
|
||||
│ ├── PrjDataSource.java # 数据源配置
|
||||
│ ├── RateLimiterConfig.java # 限流器配置
|
||||
│ ├── SpringMvcConfig.java # MVC 配置
|
||||
│ └── SwaggerKnife4j.java # Swagger 配置
|
||||
├── controller/ # 控制器
|
||||
│ └── LoginController.java # 登录相关接口
|
||||
├── common/ # 通用组件
|
||||
│ ├── constants/ # 常量定义
|
||||
│ └── util/ # 工具类
|
||||
│ ├── StringUtils.java
|
||||
│ └── LeakyBucketRateLimiter.java # 漏桶限流器
|
||||
├── exception/ # 异常处理
|
||||
│ └── RateLimitExceededException.java
|
||||
└── dev/ # 开发工具
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 数据源管理
|
||||
|
||||
当前版本使用了`苞米豆`的[动态数据源](https://github.com/baomidou/dynamic-datasource-spring-boot-starter)。
|
||||
|
||||
**注意事项**:使用事务时需使用 `@DSTransactional` 代替 `@Transactional`。
|
||||
|
||||
### 2. 限流控制
|
||||
|
||||
实现了基于漏桶算法的限流器,支持:
|
||||
|
||||
- 桶容量配置
|
||||
- 流出速率配置
|
||||
- 自定义异常处理
|
||||
- AOP 无侵入式集成
|
||||
|
||||
**使用方式**:
|
||||
```java
|
||||
@RateLimit
|
||||
public RestResult<?> doLogin(String username, String password, Boolean encrypt) {
|
||||
// 方法实现
|
||||
}
|
||||
```
|
||||
|
||||
**配置参数**:
|
||||
```yaml
|
||||
rate:
|
||||
limiter:
|
||||
enabled: true # 是否启用限流
|
||||
capacity: 100 # 桶容量
|
||||
rate: 10 # 每秒处理数量
|
||||
rate-interval: 1 # 时间间隔(秒)
|
||||
```
|
||||
|
||||
### 3. 认证授权
|
||||
|
||||
基于 Sa-Token 实现的认证授权系统:
|
||||
|
||||
- Token 认证
|
||||
- 权限管理
|
||||
- 会话管理
|
||||
- 登录状态控制
|
||||
|
||||
### 4. 安全防护
|
||||
|
||||
- **XSS 防护**:过滤恶意脚本,保护应用安全
|
||||
- **SM3 加密**:用户密码加密存储
|
||||
- **RSA 加密**:敏感数据传输加密
|
||||
- **CSP 策略**:内容安全策略配置
|
||||
|
||||
### 5. API 文档
|
||||
|
||||
集成 Knife4j (Swagger) 提供在线 API 文档:
|
||||
|
||||
- 自动生成接口文档
|
||||
- 支持在线测试
|
||||
- 权限控制访问
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 主要配置项
|
||||
|
||||
```yaml
|
||||
# 应用配置
|
||||
spring:
|
||||
application:
|
||||
name: youfool-prj-springboot-template
|
||||
|
||||
# 数据源配置
|
||||
datasource:
|
||||
dynamic:
|
||||
primary: master
|
||||
datasource:
|
||||
master:
|
||||
# 主数据源配置
|
||||
youfool:
|
||||
# 框架数据源配置
|
||||
|
||||
# 限流器配置
|
||||
rate:
|
||||
limiter:
|
||||
enabled: true
|
||||
capacity: 100
|
||||
rate: 10
|
||||
rate-interval: 1
|
||||
|
||||
# Sa-Token 配置
|
||||
sa-token:
|
||||
token-name: satoken
|
||||
timeout: 2592000
|
||||
is-concurrent: true
|
||||
is-share: false
|
||||
token-style: uuid
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
xss:
|
||||
enabled: true
|
||||
validate-input: true
|
||||
sanitize-output: true
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 启动项目
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
### 访问地址
|
||||
|
||||
- **应用首页**:http://localhost:8080/prj
|
||||
- **API 文档**:http://localhost:8080/prj/doc.html
|
||||
- **Druid 监控**:http://localhost:8080/prj/druid
|
||||
|
||||
### 代码规范
|
||||
|
||||
项目集成了 CheckStyle 代码风格检查:
|
||||
|
||||
```bash
|
||||
mvn checkstyle:check
|
||||
```
|
||||
|
||||
### 构建部署
|
||||
|
||||
```bash
|
||||
# 编译打包
|
||||
mvn clean package
|
||||
|
||||
# 跳过测试打包
|
||||
mvn clean package -DskipTests
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **事务管理**:使用 `@DSTransactional` 而非 `@Transactional`
|
||||
2. **安全配置**:生产环境请修改默认密码和密钥
|
||||
3. **限流配置**:根据业务需求调整限流参数
|
||||
4. **数据源**:确保数据库连接配置正确
|
||||
5. **权限管理**:根据实际业务配置用户权限
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目遵循 Apache License 2.0 开源协议。
|
||||
10
pom.xml
10
pom.xml
|
|
@ -12,12 +12,12 @@
|
|||
<url>https://www.chinaweal.com.cn</url>
|
||||
<description>boot基础的后台模板</description>
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
<java.version>21</java.version>
|
||||
<skipTests>true</skipTests>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
<maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.compilerVersion>21</maven.compiler.compilerVersion>
|
||||
<failOnMissingWebXml>false</failOnMissingWebXml>
|
||||
<spring.boot.version>2.3.5.RELEASE</spring.boot.version>
|
||||
<skipTests>true</skipTests>
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.12</version>
|
||||
<version>1.18.30</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package io.lroyia.ccdemo.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 限流注解
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RateLimit {
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package io.lroyia.ccdemo.aspect;
|
||||
|
||||
import io.lroyia.ccdemo.common.util.LeakyBucketRateLimiter;
|
||||
import io.lroyia.ccdemo.exception.RateLimitExceededException;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 限流器切面
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
public class RateLimiterAspect {
|
||||
|
||||
@Autowired(required = false)
|
||||
private LeakyBucketRateLimiter leakyBucketRateLimiter;
|
||||
|
||||
@Around("@annotation(io.lroyia.ccdemo.annotation.RateLimit)")
|
||||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
if (leakyBucketRateLimiter != null) {
|
||||
if (!leakyBucketRateLimiter.tryAcquire()) {
|
||||
throw new RateLimitExceededException("请求过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package io.lroyia.ccdemo.common.util;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 漏桶算法限流器
|
||||
*
|
||||
* 漏桶算法通过控制请求的流出速率来实现限流。请求以任意速率进入桶中,但以固定速率流出。
|
||||
* 如果桶满了,新的请求会被丢弃。
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class LeakyBucketRateLimiter {
|
||||
|
||||
private final long capacity;
|
||||
private final long rate;
|
||||
private final long rateIntervalNanos;
|
||||
private final AtomicLong water;
|
||||
private volatile long lastLeakTime;
|
||||
|
||||
public LeakyBucketRateLimiter(long capacity, long rate, long rateInterval, TimeUnit timeUnit) {
|
||||
if (capacity <= 0) {
|
||||
throw new IllegalArgumentException("Capacity must be positive");
|
||||
}
|
||||
if (rate <= 0) {
|
||||
throw new IllegalArgumentException("Rate must be positive");
|
||||
}
|
||||
if (rateInterval <= 0) {
|
||||
throw new IllegalArgumentException("Rate interval must be positive");
|
||||
}
|
||||
|
||||
this.capacity = capacity;
|
||||
this.rate = rate;
|
||||
this.rateIntervalNanos = timeUnit.toNanos(rateInterval);
|
||||
this.water = new AtomicLong(0);
|
||||
this.lastLeakTime = System.nanoTime();
|
||||
}
|
||||
|
||||
public boolean tryAcquire() {
|
||||
return tryAcquire(1);
|
||||
}
|
||||
|
||||
public boolean tryAcquire(int permits) {
|
||||
if (permits <= 0) {
|
||||
throw new IllegalArgumentException("Permits must be positive");
|
||||
}
|
||||
|
||||
long now = System.nanoTime();
|
||||
long waterLevel = water.get();
|
||||
|
||||
leak(now, waterLevel);
|
||||
|
||||
if (waterLevel + permits <= capacity) {
|
||||
if (water.compareAndSet(waterLevel, waterLevel + permits)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void leak(long now, long currentWaterLevel) {
|
||||
long timeSinceLastLeak = now - lastLeakTime;
|
||||
|
||||
if (timeSinceLastLeak >= rateIntervalNanos) {
|
||||
long leakAmount = (timeSinceLastLeak / rateIntervalNanos) * rate;
|
||||
|
||||
if (leakAmount > 0) {
|
||||
long newWaterLevel = Math.max(0, currentWaterLevel - leakAmount);
|
||||
if (water.compareAndSet(currentWaterLevel, newWaterLevel)) {
|
||||
lastLeakTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long getCurrentWaterLevel() {
|
||||
long now = System.nanoTime();
|
||||
long waterLevel = water.get();
|
||||
leak(now, waterLevel);
|
||||
return water.get();
|
||||
}
|
||||
|
||||
public long getCapacity() {
|
||||
return capacity;
|
||||
}
|
||||
|
||||
public long getRate() {
|
||||
return rate;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
water.set(0);
|
||||
lastLeakTime = System.nanoTime();
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private long capacity = 100;
|
||||
private long rate = 10;
|
||||
private long rateInterval = 1;
|
||||
private TimeUnit timeUnit = TimeUnit.SECONDS;
|
||||
|
||||
public Builder capacity(long capacity) {
|
||||
this.capacity = capacity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder rate(long rate) {
|
||||
this.rate = rate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder rateInterval(long rateInterval) {
|
||||
this.rateInterval = rateInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder timeUnit(TimeUnit timeUnit) {
|
||||
this.timeUnit = timeUnit;
|
||||
return this;
|
||||
}
|
||||
|
||||
public LeakyBucketRateLimiter build() {
|
||||
return new LeakyBucketRateLimiter(capacity, rate, rateInterval, timeUnit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package io.lroyia.ccdemo.config;
|
||||
|
||||
import io.lroyia.ccdemo.common.util.LeakyBucketRateLimiter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 限流器配置类
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "rate.limiter")
|
||||
public class RateLimiterConfig {
|
||||
|
||||
private boolean enabled = true;
|
||||
private long capacity = 100;
|
||||
private long rate = 10;
|
||||
private long rateInterval = 1;
|
||||
|
||||
@Bean
|
||||
public LeakyBucketRateLimiter leakyBucketRateLimiter() {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
return LeakyBucketRateLimiter.builder()
|
||||
.capacity(capacity)
|
||||
.rate(rate)
|
||||
.rateInterval(rateInterval)
|
||||
.timeUnit(TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public long getCapacity() {
|
||||
return capacity;
|
||||
}
|
||||
|
||||
public void setCapacity(long capacity) {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public long getRate() {
|
||||
return rate;
|
||||
}
|
||||
|
||||
public void setRate(long rate) {
|
||||
this.rate = rate;
|
||||
}
|
||||
|
||||
public long getRateInterval() {
|
||||
return rateInterval;
|
||||
}
|
||||
|
||||
public void setRateInterval(long rateInterval) {
|
||||
this.rateInterval = rateInterval;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.chinaweal.youfool.framework.springboot.common.util.RSAUtil;
|
|||
import com.chinaweal.youfool.framework.springboot.rest.RestResult;
|
||||
import com.chinaweal.youfool.framework.springboot.rest.ResultCode;
|
||||
import com.chinaweal.youfool.framework.springboot.user.entity.UserBase;
|
||||
import io.lroyia.ccdemo.annotation.RateLimit;
|
||||
import io.lroyia.ccdemo.common.constants.SessionConstants;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
|
@ -41,6 +42,7 @@ public class LoginController extends BaseController {
|
|||
* @since 2022年4月20日 15:47:35
|
||||
*/
|
||||
@PostMapping("login")
|
||||
@RateLimit
|
||||
public RestResult<?> doLogin(String username, String password, Boolean encrypt) {
|
||||
AssertUtils.isNotBlank(username, password);
|
||||
if (encrypt != null && encrypt) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package io.lroyia.ccdemo.exception;
|
||||
|
||||
/**
|
||||
* 限流超出异常
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class RateLimitExceededException extends RuntimeException {
|
||||
|
||||
public RateLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public RateLimitExceededException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package io.lroyia.ccdemo.security.config;
|
||||
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import io.lroyia.ccdemo.security.filter.XSSFilter;
|
||||
import io.lroyia.ccdemo.security.filter.SecurityHeadersFilter;
|
||||
|
||||
@Configuration
|
||||
public class XSSProtectionConfig implements WebMvcConfigurer {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<XSSFilter> xssFilterRegistration() {
|
||||
FilterRegistrationBean<XSSFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new XSSFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setName("xssFilter");
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<SecurityHeadersFilter> securityHeadersFilterRegistration() {
|
||||
FilterRegistrationBean<SecurityHeadersFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new SecurityHeadersFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setName("securityHeadersFilter");
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package io.lroyia.ccdemo.security.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "security.xss")
|
||||
public class XSSProtectionProperties {
|
||||
|
||||
private boolean enabled = true;
|
||||
private boolean validateInput = true;
|
||||
private boolean sanitizeOutput = true;
|
||||
private boolean enableCSP = true;
|
||||
private String cspPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
|
||||
private String[] excludedPaths = {"/druid/**", "/swagger-resources/**", "/v2/api-docs/**", "/webjars/**"};
|
||||
private String[] staticResourcePaths = {"**/*.css", "**/*.js", "**/*.jpg", "**/*.png", "**/*.gif", "**/*.ico", "**/*.woff", "**/*.woff2", "**/*.ttf", "**/*.eot"};
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isValidateInput() {
|
||||
return validateInput;
|
||||
}
|
||||
|
||||
public void setValidateInput(boolean validateInput) {
|
||||
this.validateInput = validateInput;
|
||||
}
|
||||
|
||||
public boolean isSanitizeOutput() {
|
||||
return sanitizeOutput;
|
||||
}
|
||||
|
||||
public void setSanitizeOutput(boolean sanitizeOutput) {
|
||||
this.sanitizeOutput = sanitizeOutput;
|
||||
}
|
||||
|
||||
public boolean isEnableCSP() {
|
||||
return enableCSP;
|
||||
}
|
||||
|
||||
public void setEnableCSP(boolean enableCSP) {
|
||||
this.enableCSP = enableCSP;
|
||||
}
|
||||
|
||||
public String getCspPolicy() {
|
||||
return cspPolicy;
|
||||
}
|
||||
|
||||
public void setCspPolicy(String cspPolicy) {
|
||||
this.cspPolicy = cspPolicy;
|
||||
}
|
||||
|
||||
public String[] getExcludedPaths() {
|
||||
return excludedPaths;
|
||||
}
|
||||
|
||||
public void setExcludedPaths(String[] excludedPaths) {
|
||||
this.excludedPaths = excludedPaths;
|
||||
}
|
||||
|
||||
public String[] getStaticResourcePaths() {
|
||||
return staticResourcePaths;
|
||||
}
|
||||
|
||||
public void setStaticResourcePaths(String[] staticResourcePaths) {
|
||||
this.staticResourcePaths = staticResourcePaths;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package io.lroyia.ccdemo.security.filter;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class SecurityHeadersFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("X-Frame-Options", "DENY");
|
||||
response.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
response.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
String csp = "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
"font-src 'self'; " +
|
||||
"connect-src 'self'; " +
|
||||
"media-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"require-trusted-types-for 'script';";
|
||||
|
||||
response.setHeader("Content-Security-Policy", csp);
|
||||
response.setHeader("Content-Security-Policy-Report-Only", csp);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.startsWith("/prj/druid") ||
|
||||
path.startsWith("/prj/swagger-resources") ||
|
||||
path.startsWith("/prj/v2/api-docs") ||
|
||||
path.startsWith("/prj/webjars");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package io.lroyia.ccdemo.security.filter;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletRequestWrapper;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class XSSFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String[] XSS_PATTERNS = {
|
||||
"<script.*?>.*?</script.*?>",
|
||||
"<iframe.*?>.*?</iframe.*?>",
|
||||
"<object.*?>.*?</object.*?>",
|
||||
"<embed.*?>.*?</embed.*?>",
|
||||
"<applet.*?>.*?</applet.*?>",
|
||||
"<form.*?>.*?</form.*?>",
|
||||
"<input.*?>",
|
||||
"<img.*?>",
|
||||
"<a.*?>.*?</a.*?>",
|
||||
"javascript:",
|
||||
"vbscript:",
|
||||
"onload=",
|
||||
"onerror=",
|
||||
"onclick=",
|
||||
"onmouseover=",
|
||||
"onmouseout=",
|
||||
"onfocus=",
|
||||
"onblur=",
|
||||
"onchange=",
|
||||
"onsubmit=",
|
||||
"onreset=",
|
||||
"onselect=",
|
||||
"onunload=",
|
||||
"onabort=",
|
||||
"onkeydown=",
|
||||
"onkeyup=",
|
||||
"onkeypress=",
|
||||
"ondblclick=",
|
||||
"onmousedown=",
|
||||
"onmouseup=",
|
||||
"onmousemove=",
|
||||
"expression\\(",
|
||||
"<.*?\\+.*?>",
|
||||
"<.*?\\|.*?>",
|
||||
"<.*?&.*?>",
|
||||
"<.*?\\$.*?>"
|
||||
};
|
||||
|
||||
private static final Pattern[] XSS_REGEX_PATTERNS = new Pattern[XSS_PATTERNS.length];
|
||||
|
||||
static {
|
||||
for (int i = 0; i < XSS_PATTERNS.length; i++) {
|
||||
XSS_REGEX_PATTERNS[i] = Pattern.compile(XSS_PATTERNS[i], Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request);
|
||||
filterChain.doFilter(wrappedRequest, response);
|
||||
}
|
||||
|
||||
private static class XSSRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
public XSSRequestWrapper(HttpServletRequest request) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
String value = super.getParameter(name);
|
||||
return sanitizeXSS(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
String[] values = super.getParameterValues(name);
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] sanitizedValues = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
sanitizedValues[i] = sanitizeXSS(values[i]);
|
||||
}
|
||||
return sanitizedValues;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
String value = super.getHeader(name);
|
||||
return sanitizeXSS(value);
|
||||
}
|
||||
|
||||
private String sanitizeXSS(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String cleanValue = value;
|
||||
|
||||
for (Pattern pattern : XSS_REGEX_PATTERNS) {
|
||||
cleanValue = pattern.matcher(cleanValue).replaceAll("");
|
||||
}
|
||||
|
||||
cleanValue = cleanValue.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("/", "/");
|
||||
|
||||
return cleanValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.startsWith("/prj/druid") ||
|
||||
path.startsWith("/prj/swagger-resources") ||
|
||||
path.startsWith("/prj/v2/api-docs") ||
|
||||
path.startsWith("/prj/webjars") ||
|
||||
path.endsWith(".css") ||
|
||||
path.endsWith(".js") ||
|
||||
path.endsWith(".jpg") ||
|
||||
path.endsWith(".png") ||
|
||||
path.endsWith(".gif") ||
|
||||
path.endsWith(".ico");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package io.lroyia.ccdemo.security.util;
|
||||
|
||||
import org.apache.commons.text.StringEscapeUtils;
|
||||
import org.owasp.encoder.Encode;
|
||||
|
||||
public class OutputEncoder {
|
||||
|
||||
public static String forHtml(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forHtml(input);
|
||||
}
|
||||
|
||||
public static String forHtmlContent(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forHtmlContent(input);
|
||||
}
|
||||
|
||||
public static String forHtmlAttribute(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forHtmlAttribute(input);
|
||||
}
|
||||
|
||||
public static String forJavaScript(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forJavaScript(input);
|
||||
}
|
||||
|
||||
public static String forJavaScriptBlock(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forJavaScriptBlock(input);
|
||||
}
|
||||
|
||||
public static String forCssString(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forCssString(input);
|
||||
}
|
||||
|
||||
public static String forCssUrl(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forCssUrl(input);
|
||||
}
|
||||
|
||||
public static String forUri(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forUri(input);
|
||||
}
|
||||
|
||||
public static String forXmlAttribute(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Encode.forXmlAttribute(input);
|
||||
}
|
||||
|
||||
public static String forJava(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return StringEscapeUtils.escapeJava(input);
|
||||
}
|
||||
|
||||
public static String forJson(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return StringEscapeUtils.escapeJson(input);
|
||||
}
|
||||
|
||||
public static String forXml(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return StringEscapeUtils.escapeXml11(input);
|
||||
}
|
||||
|
||||
public static String forSql(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return input.replace("'", "''")
|
||||
.replace("\"", "\"\"")
|
||||
.replace("\\", "\\\\");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package io.lroyia.ccdemo.security.validator;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class XSSValidator {
|
||||
|
||||
private static final String[] MALICIOUS_PATTERNS = {
|
||||
"<script.*?>.*?</script.*?>",
|
||||
"<iframe.*?>.*?</iframe.*?>",
|
||||
"<object.*?>.*?</object.*?>",
|
||||
"<embed.*?>.*?</embed.*?>",
|
||||
"<applet.*?>.*?</applet.*?>",
|
||||
"<form.*?>.*?</form.*?>",
|
||||
"<input.*?>",
|
||||
"<img.*?>",
|
||||
"<a.*?>.*?</a.*?>",
|
||||
"javascript:",
|
||||
"vbscript:",
|
||||
"data:text/html",
|
||||
"onload=",
|
||||
"onerror=",
|
||||
"onclick=",
|
||||
"onmouseover=",
|
||||
"onmouseout=",
|
||||
"onfocus=",
|
||||
"onblur=",
|
||||
"onchange=",
|
||||
"onsubmit=",
|
||||
"onreset=",
|
||||
"onselect=",
|
||||
"onunload=",
|
||||
"onabort=",
|
||||
"onkeydown=",
|
||||
"onkeyup=",
|
||||
"onkeypress=",
|
||||
"ondblclick=",
|
||||
"onmousedown=",
|
||||
"onmouseup=",
|
||||
"onmousemove=",
|
||||
"expression\\(",
|
||||
"eval\\(",
|
||||
"alert\\(",
|
||||
"confirm\\(",
|
||||
"prompt\\(",
|
||||
"document\\.",
|
||||
"window\\.",
|
||||
"location\\.",
|
||||
"cookie\\.",
|
||||
"sessionStorage\\.",
|
||||
"localStorage\\.",
|
||||
"<.*?\\+.*?>",
|
||||
"<.*?\\|.*?>",
|
||||
"<.*?&.*?>",
|
||||
"<.*?\\$.*?>",
|
||||
"<.*?%.*?>",
|
||||
"<.*?#.*?>",
|
||||
"<.*?@.*?>",
|
||||
"<.*?!.*?>",
|
||||
"<.*?\\*.*?>",
|
||||
"<.*?\\^.*?>",
|
||||
"<.*?~.*?>",
|
||||
"<.*?`.*?>",
|
||||
"<.*?\\{.*?>",
|
||||
"<.*?\\}.*?>",
|
||||
"<.*?\\[.*?>",
|
||||
"<.*?\\].*?>",
|
||||
"<.*?\\(.*?>",
|
||||
"<.*?\\).*?>",
|
||||
"<.*?\\\\.*?>",
|
||||
"<.*?\\/.*?>"
|
||||
};
|
||||
|
||||
private static final Pattern[] MALICIOUS_REGEX_PATTERNS = new Pattern[MALICIOUS_PATTERNS.length];
|
||||
|
||||
static {
|
||||
for (int i = 0; i < MALICIOUS_PATTERNS.length; i++) {
|
||||
MALICIOUS_REGEX_PATTERNS[i] = Pattern.compile(MALICIOUS_PATTERNS[i], Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean containsXSS(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Pattern pattern : MALICIOUS_REGEX_PATTERNS) {
|
||||
if (pattern.matcher(input).find()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void validateInput(String input, String fieldName) throws SecurityException {
|
||||
if (containsXSS(input)) {
|
||||
throw new SecurityException("Potential XSS attack detected in field: " + fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
public void validateInput(String[] inputs, String fieldName) throws SecurityException {
|
||||
if (inputs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (String input : inputs) {
|
||||
if (containsXSS(input)) {
|
||||
throw new SecurityException("Potential XSS attack detected in field: " + fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String sanitizeInput(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String cleanValue = input;
|
||||
|
||||
for (Pattern pattern : MALICIOUS_REGEX_PATTERNS) {
|
||||
cleanValue = pattern.matcher(cleanValue).replaceAll("");
|
||||
}
|
||||
|
||||
cleanValue = cleanValue.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("/", "/");
|
||||
|
||||
return cleanValue;
|
||||
}
|
||||
|
||||
public boolean isValidInput(String input) {
|
||||
return !containsXSS(input);
|
||||
}
|
||||
|
||||
public boolean isSafeHtml(String input) {
|
||||
if (input == null || input.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String lowerInput = input.toLowerCase();
|
||||
|
||||
if (lowerInput.contains("<script") || lowerInput.contains("</script>")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lowerInput.contains("javascript:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lowerInput.contains("onload=") || lowerInput.contains("onerror=")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package io.lroyia.ccdemo.system.controller;
|
||||
|
||||
import com.chinaweal.youfool.framework.springboot.common.base.BaseController;
|
||||
import com.chinaweal.youfool.framework.springboot.rest.RestResult;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 文件上传接口
|
||||
*
|
||||
* @author lroyia
|
||||
* @since 2025/9/20
|
||||
**/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/system/file")
|
||||
public class FileUploadController extends BaseController {
|
||||
|
||||
@Value("${file.upload.path}")
|
||||
private String uploadPath;
|
||||
|
||||
/**
|
||||
* 文件上传接口
|
||||
*
|
||||
* @param file 多部件文件
|
||||
* @return 上传结果
|
||||
* @author lroyia
|
||||
* @since 2025年9月20日
|
||||
*/
|
||||
@PostMapping("/upload")
|
||||
public RestResult<String> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||
try {
|
||||
if (file.isEmpty()) {
|
||||
return RestResult.error("文件不能为空");
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isEmpty()) {
|
||||
return RestResult.error("文件名不能为空");
|
||||
}
|
||||
|
||||
File uploadDir = new File(uploadPath);
|
||||
if (!uploadDir.exists()) {
|
||||
uploadDir.mkdirs();
|
||||
}
|
||||
|
||||
String fileExtension = "";
|
||||
int dotIndex = originalFilename.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
fileExtension = originalFilename.substring(dotIndex);
|
||||
}
|
||||
|
||||
String uniqueFileName = System.currentTimeMillis() + "_" + originalFilename;
|
||||
Path filePath = Paths.get(uploadPath, uniqueFileName);
|
||||
|
||||
Files.copy(file.getInputStream(), filePath);
|
||||
|
||||
log.info("文件上传成功: {} -> {}", originalFilename, filePath.toString());
|
||||
|
||||
return RestResult.ok("文件上传成功");
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("文件上传失败", e);
|
||||
return RestResult.error("文件上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,18 @@ knife4j:
|
|||
# username: admin
|
||||
# password: 123456
|
||||
|
||||
file:
|
||||
upload:
|
||||
path: D:/uploads
|
||||
|
||||
# 限流器配置
|
||||
rate:
|
||||
limiter:
|
||||
enabled: true
|
||||
capacity: 100
|
||||
rate: 10
|
||||
rate-interval: 1
|
||||
|
||||
server:
|
||||
servlet:
|
||||
context-path: /prj
|
||||
|
|
@ -84,4 +96,28 @@ sa-token:
|
|||
# token风格
|
||||
token-style: uuid
|
||||
# 是否输出操作日志
|
||||
is-log: false
|
||||
is-log: false
|
||||
|
||||
security:
|
||||
xss:
|
||||
enabled: true
|
||||
validate-input: true
|
||||
sanitize-output: true
|
||||
enable-csp: true
|
||||
csp-policy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';"
|
||||
excluded-paths:
|
||||
- "/druid/**"
|
||||
- "/swagger-resources/**"
|
||||
- "/v2/api-docs/**"
|
||||
- "/webjars/**"
|
||||
static-resource-paths:
|
||||
- "**/*.css"
|
||||
- "**/*.js"
|
||||
- "**/*.jpg"
|
||||
- "**/*.png"
|
||||
- "**/*.gif"
|
||||
- "**/*.ico"
|
||||
- "**/*.woff"
|
||||
- "**/*.woff2"
|
||||
- "**/*.ttf"
|
||||
- "**/*.eot"
|
||||
Loading…
Reference in New Issue