package jnpf.dmsPermission; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.util.Base64; import java.util.UUID; @Component @RequiredArgsConstructor public class DmsFileCenterCursorCodec { private static final String VERSION = "1"; private static final String HMAC_ALGORITHM = "HmacSHA256"; private final DmsPermissionProperties properties; public String encode(String kind, String tenantId, String userId, UUID parentId, String queryFingerprint, Cursor cursor) { String payload = String.join("|", VERSION, field(kind), field(tenantId), field(userId), field(parentId == null ? null : parentId.toString()), field(queryFingerprint), field(cursor.getName()), field(uuid(cursor.getDocumentId())), field(cursor.getVersionNo() == null ? null : cursor.getVersionNo().toString()), field(uuid(cursor.getId()))); return base64(payload.getBytes(StandardCharsets.UTF_8)) + "." + base64(sign(payload)); } public Cursor decode(String value, String expectedKind, String expectedTenantId, String expectedUserId, UUID expectedParentId, String expectedFingerprint) { if (value == null || value.trim().isEmpty()) { return new Cursor(null, null, null, null); } try { String[] token = value.split("\\.", -1); if (token.length != 2) { throw new IllegalArgumentException("cursor token parts"); } String payload = new String(Base64.getUrlDecoder().decode(token[0]), StandardCharsets.UTF_8); byte[] providedSignature = Base64.getUrlDecoder().decode(token[1]); if (!MessageDigest.isEqual(providedSignature, sign(payload))) { throw new IllegalArgumentException("cursor signature"); } String[] parts = payload.split("\\|", -1); if (parts.length != 10 || !VERSION.equals(parts[0])) { throw new IllegalArgumentException("cursor payload"); } String kind = unfield(parts[1]); String tenantId = unfield(parts[2]); String userId = unfield(parts[3]); UUID parentId = uuid(unfield(parts[4])); String fingerprint = unfield(parts[5]); if (!expectedKind.equals(kind) || !expectedTenantId.equals(tenantId) || !expectedUserId.equals(userId) || !java.util.Objects.equals(expectedParentId, parentId) || !expectedFingerprint.equals(fingerprint)) { throw new IllegalArgumentException("cursor context"); } return new Cursor( unfield(parts[6]), uuid(unfield(parts[7])), number(unfield(parts[8])), uuid(unfield(parts[9]))); } catch (RuntimeException ex) { throw DmsPermissionException.of(DmsPermissionError.INVALID_REQUEST, "分页 cursor 无效或不属于当前用户、目录和查询条件"); } } public String fingerprint(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); return base64(digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8))); } catch (GeneralSecurityException ex) { throw new IllegalStateException("SHA-256 unavailable", ex); } } private byte[] sign(String payload) { String secret = properties.getCursorSigningSecret(); if (secret == null || secret.length() < 16) { throw new IllegalStateException("dms.permission.cursor-signing-secret 至少需要 16 个字符"); } try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); } catch (GeneralSecurityException ex) { throw new IllegalStateException("HMAC-SHA256 unavailable", ex); } } private String field(String value) { return value == null ? "" : base64(value.getBytes(StandardCharsets.UTF_8)); } private String unfield(String value) { return value.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); } private String base64(byte[] value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value); } private String uuid(UUID value) { return value == null ? null : value.toString(); } private UUID uuid(String value) { return value == null ? null : UUID.fromString(value); } private Long number(String value) { return value == null ? null : Long.valueOf(value); } @Data @AllArgsConstructor public static class Cursor { private String name; private UUID documentId; private Long versionNo; private UUID id; } }