fix(p1r): 加固 Account 导出凭证消费边界

This commit is contained in:
zizi 2026-05-30 01:23:35 +08:00
parent 2ef35aedb3
commit a37dc80609
3 changed files with 192 additions and 33 deletions

View File

@ -17,10 +17,13 @@ import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
import cn.iocoder.muse.module.member.domain.account.AccountDownloadGuard;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@ -72,6 +75,8 @@ public class AccountExportServiceImpl implements AccountExportService {
private AccountAuditService auditService;
@Resource
private AccountFileServiceFacade fileServiceFacade;
@Value("${muse.account.download-credential-secret:}")
private String downloadCredentialSecret;
@Override
@Transactional(rollbackFor = Exception.class)
@ -101,7 +106,7 @@ public class AccountExportServiceImpl implements AccountExportService {
String taskId = newTaskId();
String sourceSnapshot = sourceSnapshot(exportRequest);
AccountFileServiceFacade.ExportTaskCreateResult fileResult = fileServiceFacade.createExportTask(
AccountFileServiceFacade.ExportTaskCreateResult fileResult = createExportTaskSafely(
AccountFileServiceFacade.ExportTaskCreateCommand.builder()
.accountUserId(accountUserId)
.taskId(taskId)
@ -156,22 +161,21 @@ public class AccountExportServiceImpl implements AccountExportService {
if (credential == null) {
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
}
LocalDateTime now = LocalDateTime.now();
AccountDownloadGuard.requireUsableCredential(accountUserId, credential.getAccountUserId(),
credential.getExpiresAt(), credential.getConsumedAt(), credential.getRevokedAt(),
Boolean.TRUE.equals(credential.getSourceBlocked()), LocalDateTime.now());
Boolean.TRUE.equals(credential.getSourceBlocked()), now);
AccountExportTaskDO task = exportTaskMapper.selectByTaskIdAndAccountUserId(credential.getTaskId(), accountUserId);
requireDownloadableTask(task);
AccountFileServiceFacade.DownloadPackageResult result =
fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef());
int consumedRows = downloadCredentialMapper.markConsumedIfUsable(credentialHash, accountUserId, now, now);
if (consumedRows != 1) {
// 凭证消费是一次性原子门禁校验后被撤销过期阻断或并发消费时必须在读文件前失败关闭
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
}
AccountFileServiceFacade.DownloadPackageResult result = downloadExportPackageSafely(accountUserId, task);
if (result == null || !result.available() || result.bytes() == null || result.bytes().length == 0) {
throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE);
}
LocalDateTime consumedAt = LocalDateTime.now();
int consumedRows = downloadCredentialMapper.markConsumed(credentialHash, consumedAt);
if (consumedRows != 1) {
// 凭证消费是一次性门禁并发下载在读取后抢先消费时后续请求必须失败关闭
throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
}
auditService.record(AccountAuditService.AuditCreateReq.builder()
.operationId(OPERATION_DOWNLOAD_EXPORT)
.actorUserId(accountUserId)
@ -189,6 +193,25 @@ public class AccountExportServiceImpl implements AccountExportService {
return new DownloadPackage(defaultFilename(result.filename(), task), "application/octet-stream", result.bytes());
}
private AccountFileServiceFacade.ExportTaskCreateResult createExportTaskSafely(
AccountFileServiceFacade.ExportTaskCreateCommand command) {
try {
return fileServiceFacade.createExportTask(command);
} catch (RuntimeException ex) {
// FileService 还不是 Account owner异常只能收敛为 unavailable不能泄露为 500 或伪造文件结果
return AccountFileServiceFacade.ExportTaskCreateResult.unavailable();
}
}
private AccountFileServiceFacade.DownloadPackageResult downloadExportPackageSafely(Long accountUserId,
AccountExportTaskDO task) {
try {
return fileServiceFacade.downloadExportPackage(accountUserId, task.getTaskId(), task.getFileRef());
} catch (RuntimeException ex) {
throw new ServiceException(ACCOUNT_FILE_SERVICE_UNAVAILABLE);
}
}
private MemberUserDO requireUser(Long accountUserId) {
MemberUserDO user = accountUserId == null ? null : memberUserMapper.selectById(accountUserId);
if (user == null) {
@ -240,7 +263,8 @@ public class AccountExportServiceImpl implements AccountExportService {
ExportRequest exportRequest, String sourceSnapshot,
AccountFileServiceFacade.ExportTaskCreateResult fileResult) {
String status = normalizeCreateStatus(fileResult);
String fileRef = fileResult == null || !fileResult.available() ? null : fileResult.fileRef();
String fileRef = fileResult == null || !fileResult.available() || STATUS_FAILED.equals(status)
? null : fileResult.fileRef();
AccountExportTaskDO task = AccountExportTaskDO.builder()
.taskId(taskId)
.accountUserId(accountUserId)
@ -252,7 +276,7 @@ public class AccountExportServiceImpl implements AccountExportService {
.fileRef(fileRef)
.sourceBlocked(false)
.errorCode(STATUS_FAILED.equals(status) ? ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode().toString() : null)
.errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息" : null)
.errorMessage(STATUS_FAILED.equals(status) ? "FileService 返回 completed 但缺少可下载文件信息或下载凭证 secret" : null)
.completedAt(STATUS_COMPLETED.equals(status)
? firstNonNull(fileResult == null ? null : fileResult.completedAt(), LocalDateTime.now())
: null)
@ -263,8 +287,9 @@ public class AccountExportServiceImpl implements AccountExportService {
private String normalizeCreateStatus(AccountFileServiceFacade.ExportTaskCreateResult fileResult) {
String status = fileResult == null ? null : fileResult.status();
if (STATUS_COMPLETED.equals(status)) {
if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef()) || fileResult.downloadExpiresAt() == null) {
// completed 必须同时具备真实 fileRef Account 可派生的下载凭证缺任一项都按失败关闭
if (!fileResult.available() || !StringUtils.hasText(fileResult.fileRef())
|| fileResult.downloadExpiresAt() == null || !hasDownloadCredentialSecret()) {
// completed 必须同时具备真实 fileRef过期时间和 Account HMAC secret缺任一项都按失败关闭
return STATUS_FAILED;
}
return STATUS_COMPLETED;
@ -311,7 +336,10 @@ public class AccountExportServiceImpl implements AccountExportService {
detail.setFailedReason(task.getErrorMessage());
detail.setCreatedAt(task.getCreateTime());
detail.setCompletedAt(task.getCompletedAt());
if (STATUS_COMPLETED.equals(detail.getStatus()) && isCredentialVisible(credential)) {
if (STATUS_COMPLETED.equals(detail.getStatus())
&& !Boolean.TRUE.equals(task.getSourceBlocked())
&& hasDownloadCredentialSecret()
&& isCredentialVisible(credential)) {
detail.setDownloadExpiresAt(credential.getExpiresAt());
String accountCredentialId = buildAccountCredentialId(task);
if (credential.getCredentialHash().equals(hashCredential(accountCredentialId))) {
@ -398,17 +426,25 @@ public class AccountExportServiceImpl implements AccountExportService {
}
private String buildAccountCredentialId(AccountExportTaskDO task) {
// Account 下载凭证 ID taskId/requestHash 可重算生成数据库只保存 hash不持久化也不回传 FileService 原始 token
return "acct-dl-" + sha256(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
// Account 下载凭证 ID 使用服务端 secret HMAC 派生只读数据库不能反推可用 credentialId
if (!hasDownloadCredentialSecret()) {
throw new IllegalStateException("Account 下载凭证 secret 未配置");
}
return "acct-dl-" + hmacSha256(downloadCredentialSecret, task.getTaskId() + ":" + task.getRequestHash())
.substring(0, 32);
}
private String sha256(String payload) {
private boolean hasDownloadCredentialSecret() {
return StringUtils.hasText(downloadCredentialSecret);
}
private String hmacSha256(String secret, String payload) {
try {
byte[] bytes = MessageDigest.getInstance("SHA-256")
.digest(payload.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception ex) {
throw new IllegalStateException("JDK 缺少 HmacSHA256 摘要算法或密钥不可用", ex);
}
}

View File

@ -31,11 +31,18 @@ public interface AccountDownloadCredentialMapper extends BaseMapperX<AccountDown
.last("LIMIT 1"));
}
default int markConsumed(String credentialHash, LocalDateTime consumedAt) {
default int markConsumedIfUsable(String credentialHash, Long accountUserId,
LocalDateTime now, LocalDateTime consumedAt) {
return update(null, new LambdaUpdateWrapper<AccountDownloadCredentialDO>()
.set(AccountDownloadCredentialDO::getConsumedAt, consumedAt)
.eq(AccountDownloadCredentialDO::getCredentialHash, credentialHash)
.isNull(AccountDownloadCredentialDO::getConsumedAt));
.eq(AccountDownloadCredentialDO::getAccountUserId, accountUserId)
.gt(AccountDownloadCredentialDO::getExpiresAt, now)
.isNull(AccountDownloadCredentialDO::getRevokedAt)
.isNull(AccountDownloadCredentialDO::getConsumedAt)
.and(wrapper -> wrapper.isNull(AccountDownloadCredentialDO::getSourceBlocked)
.or()
.eq(AccountDownloadCredentialDO::getSourceBlocked, false)));
}
}

View File

@ -14,10 +14,15 @@ import cn.iocoder.muse.module.member.dal.mysql.account.AccountDownloadCredential
import cn.iocoder.muse.module.member.dal.mysql.account.AccountExportTaskMapper;
import cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapper;
import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
@ -37,6 +42,8 @@ import static org.mockito.Mockito.*;
*/
class AccountExportServiceTest extends BaseMockitoUnitTest {
private static final String TEST_CREDENTIAL_SECRET = "unit-test-download-secret";
@InjectMocks
private AccountExportServiceImpl exportService;
@ -55,6 +62,11 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
@Mock
private AccountFileServiceFacade fileServiceFacade;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", TEST_CREDENTIAL_SECRET);
}
@Test
void should_createQueuedProfileExport_whenFileServiceUnavailable() {
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
@ -213,6 +225,56 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
}
@Test
void should_failClosedInternally_whenCredentialSecretMissing() {
ReflectionTestUtils.setField(exportService, "downloadCredentialSecret", "");
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
when(commandService.buildRequestHash(any())).thenReturn("hash-export");
when(commandService.reserveCommand(any())).thenReturn(null);
when(fileServiceFacade.createExportTask(any()))
.thenReturn(AccountFileServiceFacade.ExportTaskCreateResult.available("completed",
"plain-credential-token", "file-ref-1", LocalDateTime.of(2026, 5, 28, 11, 0),
LocalDateTime.of(2026, 5, 28, 10, 0)));
when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> {
AccountExportTaskDO task = invocation.getArgument(0);
task.setId(701L);
task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0));
return 1;
});
AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO);
assertEquals("queued", result.getStatus());
verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) ->
"failed".equals(task.getStatus())
&& task.getFileRef() == null
&& task.getErrorMessage().contains("secret")));
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
}
@Test
void should_createQueuedTask_whenCreateFacadeThrowsRuntimeException() {
AccountExportTaskCreateReqVO reqVO = exportReq("profile", "cmd-export-1");
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
when(commandService.buildRequestHash(any())).thenReturn("hash-export");
when(commandService.reserveCommand(any())).thenReturn(null);
when(fileServiceFacade.createExportTask(any())).thenThrow(new RuntimeException("file service down"));
when(exportTaskMapper.insert(any(AccountExportTaskDO.class))).thenAnswer(invocation -> {
AccountExportTaskDO task = invocation.getArgument(0);
task.setId(701L);
task.setCreateTime(LocalDateTime.of(2026, 5, 28, 10, 0));
return 1;
});
AccountExportTaskResultRespVO result = exportService.appCreateExportTask(1001L, reqVO);
assertEquals("queued", result.getStatus());
verify(exportTaskMapper).insert(argThat((AccountExportTaskDO task) ->
"queued".equals(task.getStatus()) && task.getFileRef() == null));
verify(downloadCredentialMapper, never()).insert(any(AccountDownloadCredentialDO.class));
}
@Test
void should_getExportTaskForOwnerOnly_withoutReturningHashedCredential() {
AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", false);
@ -227,6 +289,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
assertEquals("task-701", detail.getTaskId());
assertEquals("completed", detail.getStatus());
assertEquals(accountCredentialId, detail.getDownloadCredentialId());
assertNotEquals("acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32),
detail.getDownloadCredentialId());
assertFalse(detail.getDownloadCredentialId().contains("hash"));
assertNotNull(detail.getDownloadExpiresAt());
verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L);
@ -249,6 +313,22 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
assertNull(detail.getDownloadExpiresAt());
}
@Test
void should_hideDownloadCredentialInDetail_whenTaskSourceBlocked() {
AccountExportTaskDO task = exportTask("task-701", 1001L, "completed", "file-ref-1", true);
String accountCredentialId = accountCredentialId(task);
when(memberUserMapper.selectById(1001L)).thenReturn(user(1001L));
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L)).thenReturn(task);
when(downloadCredentialMapper.selectLatestByTaskIdAndAccountUserId("task-701", 1001L))
.thenReturn(credential(accountCredentialId, 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
AccountExportTaskDetailRespVO detail = exportService.appGetExportTask(1001L, "task-701");
assertEquals("completed", detail.getStatus());
assertNull(detail.getDownloadCredentialId());
assertNull(detail.getDownloadExpiresAt());
}
@Test
void should_downloadPackage_whenCredentialHashMatchesAndTaskCompleted() {
LocalDateTime expiresAt = LocalDateTime.now().plusHours(1);
@ -259,7 +339,8 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip",
"application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8)));
when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(1);
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
when(commandService.buildRequestHash(any())).thenReturn("hash-download");
AccountExportService.DownloadPackage result = exportService.appDownloadExport(1001L, "cred-1");
@ -267,7 +348,12 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
assertEquals("account-export.zip", result.filename());
assertArrayEquals("zip-bytes".getBytes(StandardCharsets.UTF_8), result.bytes());
verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1"));
verify(downloadCredentialMapper).markConsumed(eq(hash("cred-1")), any(LocalDateTime.class));
InOrder inOrder = inOrder(downloadCredentialMapper, exportTaskMapper, fileServiceFacade);
inOrder.verify(downloadCredentialMapper).selectByCredentialHash(hash("cred-1"));
inOrder.verify(exportTaskMapper).selectByTaskIdAndAccountUserId("task-701", 1001L);
inOrder.verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class));
inOrder.verify(fileServiceFacade).downloadExportPackage(1001L, "task-701", "file-ref-1");
verify(auditService).record(argThat(req ->
"appDownloadExport".equals(req.getOperationId())
&& "succeeded".equals(req.getStatus())
@ -280,15 +366,14 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
.thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L))
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.available("account-export.zip",
"application/octet-stream", "zip-bytes".getBytes(StandardCharsets.UTF_8)));
when(downloadCredentialMapper.markConsumed(eq(hash("cred-1")), any(LocalDateTime.class))).thenReturn(0);
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(0);
ServiceException exception = assertThrows(ServiceException.class,
() -> exportService.appDownloadExport(1001L, "cred-1"));
assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode());
verifyNoInteractions(fileServiceFacade);
verifyNoInteractions(auditService);
}
@ -336,12 +421,33 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
.thenReturn(AccountFileServiceFacade.DownloadPackageResult.unavailable());
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
ServiceException exception = assertThrows(ServiceException.class,
() -> exportService.appDownloadExport(1001L, "cred-1"));
assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode());
verify(downloadCredentialMapper, never()).markConsumed(anyString(), any());
verify(downloadCredentialMapper).markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void should_rejectDownloadAsFileServiceUnavailable_whenDownloadFacadeThrowsRuntimeException() {
when(downloadCredentialMapper.selectByCredentialHash(hash("cred-1")))
.thenReturn(credential("cred-1", 1001L, "task-701", LocalDateTime.now().plusHours(1), false));
when(exportTaskMapper.selectByTaskIdAndAccountUserId("task-701", 1001L))
.thenReturn(exportTask("task-701", 1001L, "completed", "file-ref-1", false));
when(downloadCredentialMapper.markConsumedIfUsable(eq(hash("cred-1")), eq(1001L),
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(1);
when(fileServiceFacade.downloadExportPackage(1001L, "task-701", "file-ref-1"))
.thenThrow(new RuntimeException("file service down"));
ServiceException exception = assertThrows(ServiceException.class,
() -> exportService.appDownloadExport(1001L, "cred-1"));
assertEquals(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode(), exception.getCode());
verifyNoInteractions(auditService);
}
private AccountExportTaskCreateReqVO exportReq(String exportType, String commandId) {
@ -400,6 +506,16 @@ class AccountExportServiceTest extends BaseMockitoUnitTest {
}
private String accountCredentialId(AccountExportTaskDO task) {
return "acct-dl-" + hash(task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
return "acct-dl-" + hmac(TEST_CREDENTIAL_SECRET, task.getTaskId() + ":" + task.getRequestHash()).substring(0, 32);
}
private String hmac(String secret, String payload) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}