fix(review): 审批动作校验归属、驳回必须写意见、文件下载需登录
- PUT /api/gxmu/review-list 与 /batch:操作者必须是待办行 user_id,否则 code 403 - status=2 且意见为空时拒绝并提示「驳回必须填写审核意见」 - GET /api/file/** 由匿名改为需登录,公开目录走 file.public-dirs 白名单 - 新增 FileAccessRules 纯规则组件,SecurityConfig 按其产出注册 antMatchers - 新增 ReviewListControllerAuthTest(8 例)与 FileAccessRulesTest(7 例)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package com.gxwebsoft.common.core.security;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件访问规则:{@code /api/file/**} 下哪些路径可以匿名读取。
|
||||
*
|
||||
* <p>改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料
|
||||
* 这类私人文件可以被未登录者按 URL 直接取走。现在默认全部需要登录,只有
|
||||
* {@code file.public-dirs} 里显式登记的目录(原图与缩略图)才放行;下载接口
|
||||
* {@code /api/file/download/**} 不在放行之列——"取走原件"一律要登录。
|
||||
*
|
||||
* <p>规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求,
|
||||
* 因此可以在单元测试里直接验证。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-11-03
|
||||
*/
|
||||
@Component
|
||||
public class FileAccessRules {
|
||||
|
||||
/**
|
||||
* 文件接口统一前缀
|
||||
*/
|
||||
public static final String FILE_PATH_PREFIX = "/api/file";
|
||||
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
/**
|
||||
* 允许匿名读取的目录(相对 {@code /api/file}),已去空白与首尾斜杠
|
||||
*/
|
||||
private final List<String> publicDirs;
|
||||
|
||||
public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs) {
|
||||
this.publicDirs = normalize(publicDirs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匿名放行的路径模式:白名单目录下的原图与缩略图。
|
||||
*
|
||||
* @return antMatchers 模式,白名单为空时返回空列表
|
||||
*/
|
||||
public List<String> permitAllPatterns() {
|
||||
if (publicDirs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> patterns = new ArrayList<>(publicDirs.size() * 2);
|
||||
for (String dir : publicDirs) {
|
||||
patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**");
|
||||
patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**");
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要登录才能访问的文件路径模式(兜底规则,注册时必须排在 {@code GET /**} 之前)。
|
||||
*
|
||||
* @return antMatchers 模式
|
||||
*/
|
||||
public List<String> authenticatedPatterns() {
|
||||
return Collections.singletonList(FILE_PATH_PREFIX + "/**");
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条文件请求是否按公开资源处理(匿名可读)。
|
||||
*
|
||||
* @param path 请求路径,可带查询串
|
||||
* @return true 表示命中白名单;下载路径、未命中目录、非文件路径一律 false
|
||||
*/
|
||||
public boolean isPublicFileRequest(String path) {
|
||||
if (StrUtil.isBlank(path)) {
|
||||
return false;
|
||||
}
|
||||
String clean = StrUtil.subBefore(path, "?", false);
|
||||
if (!clean.startsWith(FILE_PATH_PREFIX + "/")) {
|
||||
return false;
|
||||
}
|
||||
for (String pattern : permitAllPatterns()) {
|
||||
if (pathMatcher.match(pattern, clean)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<String> normalize(String raw) {
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> dirs = new ArrayList<>();
|
||||
for (String item : StrUtil.split(raw, ',')) {
|
||||
String dir = StrUtil.strip(StrUtil.trim(item), "/");
|
||||
if (StrUtil.isNotBlank(dir) && !dirs.contains(dir)) {
|
||||
dirs.add(dir);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Spring Security配置
|
||||
@@ -29,13 +31,25 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
@Resource
|
||||
private JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
@Resource
|
||||
private FileAccessRules fileAccessRules;
|
||||
|
||||
@Override
|
||||
protected void configure(HttpSecurity http) throws Exception {
|
||||
http.authorizeRequests()
|
||||
.antMatchers(HttpMethod.OPTIONS, "/**")
|
||||
.permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/file/**","/**", "/api/captcha", "/")
|
||||
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry urlRegistry =
|
||||
http.authorizeRequests()
|
||||
.antMatchers(HttpMethod.OPTIONS, "/**")
|
||||
.permitAll();
|
||||
// 文件默认需登录:先放行确需公开的资源目录(file.public-dirs),
|
||||
// 再兜住其余 /api/file/**。这两条必须排在 GET /** 之前,否则等于没配。
|
||||
List<String> publicFilePatterns = fileAccessRules.permitAllPatterns();
|
||||
if (!publicFilePatterns.isEmpty()) {
|
||||
urlRegistry.antMatchers(HttpMethod.GET, publicFilePatterns.toArray(new String[0]))
|
||||
.permitAll();
|
||||
}
|
||||
urlRegistry.antMatchers(HttpMethod.GET, fileAccessRules.authenticatedPatterns().toArray(new String[0]))
|
||||
.authenticated()
|
||||
.antMatchers(HttpMethod.GET, "/**", "/api/captcha", "/")
|
||||
.permitAll()
|
||||
.antMatchers(
|
||||
"/api/login",
|
||||
@@ -84,9 +98,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
.and()
|
||||
.sessionManagement()
|
||||
.authenticated();
|
||||
http.sessionManagement()
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
.and()
|
||||
.csrf()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.gxwebsoft.gxmu.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.gxmu.entity.ReviewFlow;
|
||||
import com.gxwebsoft.gxmu.entity.ReviewFlowConfig;
|
||||
@@ -98,9 +100,18 @@ public class ReviewListController extends BaseController {
|
||||
@ApiOperation("修改审核列表")
|
||||
@PutMapping()
|
||||
public ApiResult<?> update(@RequestBody ReviewList reviewList) {
|
||||
// 审批动作(通过/驳回/撤销)只能由待办的责任人本人执行
|
||||
ApiResult<?> denied = denyIfNotAssignee(reviewList.getId());
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
denied = denyIfRejectWithoutContent(reviewList);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (reviewListService.updateById(reviewList)) {
|
||||
// 通过进入下一个
|
||||
if (reviewList.getStatus().equals(1)) {
|
||||
if (Integer.valueOf(1).equals(reviewList.getStatus())) {
|
||||
ReviewFlowConfig reviewFlowConfig = reviewFlowConfigService.getByModule(reviewList.getModule());
|
||||
if (reviewFlowConfig != null) {
|
||||
ReviewFlow reviewFlow = reviewFlowService.getById(reviewFlowConfig.getFlowId());
|
||||
@@ -155,6 +166,15 @@ public class ReviewListController extends BaseController {
|
||||
@ApiOperation("批量修改审核列表")
|
||||
@PutMapping("/batch")
|
||||
public ApiResult<?> updateBatch(@RequestBody BatchParam<ReviewList> batchParam) {
|
||||
// 批量审批逐行校验归属,只要有一行不是自己的就整体拒绝,避免部分落库
|
||||
ApiResult<?> denied = denyIfBatchNotAssignee(batchParam);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
denied = denyIfRejectWithoutContent(batchParam == null ? null : batchParam.getData());
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (batchParam.update(reviewListService, "id")) {
|
||||
syncBatchReviewStatus(batchParam);
|
||||
return success("修改成功");
|
||||
@@ -162,6 +182,55 @@ public class ReviewListController extends BaseController {
|
||||
return fail("修改失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批动作的归属校验:操作者必须是该待办行的 user_id(当前一级的审批人)。
|
||||
*
|
||||
* <p>待办不存在时也按无权限处理,不区分"不存在"与"不是你的"。
|
||||
*
|
||||
* @param id 待办行 id
|
||||
* @return null 表示校验通过,否则返回 403 结果
|
||||
*/
|
||||
private ApiResult<?> denyIfNotAssignee(Integer id) {
|
||||
ReviewList existing = id == null ? null : reviewListService.getById(id);
|
||||
Integer loginUserId = getLoginUserId();
|
||||
if (existing == null || loginUserId == null || !loginUserId.equals(existing.getUserId())) {
|
||||
return new ApiResult<>(Constants.UNAUTHORIZED_CODE, Constants.UNAUTHORIZED_MSG);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量审批的归属校验:逐行确认都属于当前用户。
|
||||
*
|
||||
* @param batchParam 批量参数
|
||||
* @return null 表示全部通过,否则返回 403 结果
|
||||
*/
|
||||
private ApiResult<?> denyIfBatchNotAssignee(BatchParam<ReviewList> batchParam) {
|
||||
if (batchParam == null || batchParam.getIds() == null) {
|
||||
return null;
|
||||
}
|
||||
for (java.io.Serializable id : batchParam.getIds()) {
|
||||
ApiResult<?> denied = denyIfNotAssignee(id instanceof Number ? ((Number) id).intValue() : null);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回必须携带意见,否则申报人不知道要改什么。
|
||||
*
|
||||
* @param data 提交的审批数据
|
||||
* @return null 表示校验通过,否则返回可读的失败结果
|
||||
*/
|
||||
private ApiResult<?> denyIfRejectWithoutContent(ReviewList data) {
|
||||
if (data != null && Integer.valueOf(2).equals(data.getStatus()) && StrUtil.isBlank(data.getContent())) {
|
||||
return fail("驳回必须填写审核意见");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 批量审核后同样要回写申报记录状态,否则重复申报限制会读到旧状态 */
|
||||
private void syncBatchReviewStatus(BatchParam<ReviewList> batchParam) {
|
||||
if (batchParam == null || batchParam.getIds() == null) {
|
||||
|
||||
Reference in New Issue
Block a user