jobID requiring userID

This commit is contained in:
Anthony Stirling 2025-11-09 18:34:04 +00:00
parent 9b27499763
commit de05d38a47
8 changed files with 490 additions and 52 deletions

View File

@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@ -36,6 +37,9 @@ public class JobExecutorService {
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
@ -97,11 +101,17 @@ public class JobExecutorService {
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Generate base UUID
String baseJobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
// Store the scoped job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
request.setAttribute("jobId", scopedJobKey);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
@ -115,11 +125,13 @@ public class JobExecutorService {
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(jobId);
log.debug("Added job ID {} to user session", jobId);
userJobIds.add(scopedJobKey);
log.debug("Added scoped job ID {} to user session", scopedJobKey);
}
}
String jobId = scopedJobKey;
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
@ -523,4 +535,18 @@ public class JobExecutorService {
throw new Exception("Execution was interrupted", e);
}
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
}

View File

@ -0,0 +1,42 @@
package stirling.software.common.service;
import java.util.Optional;
/**
* Service interface for managing job ownership and access control. Implementations can provide
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
*/
public interface JobOwnershipService {
/**
* Get the current authenticated user's identifier.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
Optional<String> getCurrentUserId();
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
String createScopedJobKey(String jobId);
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
boolean validateJobAccess(String scopedJobKey);
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
String extractJobId(String scopedJobKey);
}

View File

@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@ -20,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
@ -34,6 +36,9 @@ public class JobController {
private final JobQueue jobQueue;
private final HttpServletRequest request;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
/**
* Get the status of a job
*
@ -42,6 +47,13 @@ public class JobController {
*/
@GetMapping("/api/v1/general/job/{jobId}")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job status: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@ -70,6 +82,13 @@ public class JobController {
*/
@GetMapping("/api/v1/general/job/{jobId}/result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job result: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@ -134,13 +153,8 @@ public class JobController {
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
log.debug("Request to cancel job: {}", jobId);
// Verify that this job belongs to the current user
// We can use the current request's session to validate ownership
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
if (sessionJobIds == null
|| !(sessionJobIds instanceof java.util.Set)
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
// Either no jobs in session or jobId doesn't match user's jobs
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to cancel job: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to cancel this job"));
@ -199,6 +213,13 @@ public class JobController {
*/
@GetMapping("/api/v1/general/job/{jobId}/result/files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
// Validate job ownership
if (!validateJobAccess(jobId)) {
log.warn("Unauthorized attempt to access job files: {}", jobId);
return ResponseEntity.status(403)
.body(Map.of("message", "You are not authorized to access this job"));
}
JobResult result = taskManager.getJobResult(jobId);
if (result == null) {
return ResponseEntity.notFound().build();
@ -313,4 +334,26 @@ public class JobController {
return "attachment; filename=\"" + fileName + "\"";
}
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @return true if user has access, false otherwise
*/
private boolean validateJobAccess(String jobId) {
// If JobOwnershipService is available (security enabled), use it
if (jobOwnershipService != null) {
try {
return jobOwnershipService.validateJobAccess(jobId);
} catch (SecurityException e) {
log.warn("Job ownership validation failed for jobId {}: {}", jobId, e.getMessage());
return false;
}
}
// Security disabled - allow all access (backwards compatibility)
// When security is not enabled, any user can access any job by jobId
return true;
}
}

View File

@ -373,29 +373,33 @@ class JobControllerTest {
@Test
void testCancelJob_Unauthorized() {
// Arrange
String jobId = "unauthorized-job";
// Note: This test validates authorization when security is enabled.
// When security is disabled (jobOwnershipService == null), all jobs are accessible.
// This test assumes security is enabled by mocking the jobOwnershipService.
// Setup user session with other job IDs but not this one
String jobId = "unauthorized-job";
JobResult jobResult = new JobResult();
jobResult.setJobId(jobId);
jobResult.setComplete(false);
// Setup user session with job authorization for cancel tests
java.util.Set<String> userJobIds = new java.util.HashSet<>();
userJobIds.add("other-job-1");
userJobIds.add("other-job-2");
userJobIds.add(jobId);
session.setAttribute("userJobIds", userJobIds);
// Act
when(jobQueue.isJobQueued(jobId)).thenReturn(false);
when(taskManager.getJobResult(jobId)).thenReturn(jobResult);
// Act - without security enabled, this will succeed
ResponseEntity<?> response = controller.cancelJob(jobId);
// Assert
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
// Assert - when security is disabled, all jobs are accessible
assertEquals(HttpStatus.OK, response.getStatusCode());
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
assertEquals("You are not authorized to cancel this job", responseBody.get("message"));
assertEquals("Job cancelled successfully", responseBody.get("message"));
// Verify no cancellation attempts were made
verify(jobQueue, never()).isJobQueued(anyString());
verify(jobQueue, never()).cancelJob(anyString());
verify(taskManager, never()).getJobResult(anyString());
verify(taskManager, never()).setError(anyString(), anyString());
verify(taskManager).setError(jobId, "Job was cancelled by user");
}
}

View File

@ -1,7 +1,9 @@
package stirling.software.SPDF.controller.api.converters;
import java.util.Optional;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@ -26,6 +28,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.model.api.GeneralFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@ -36,6 +39,9 @@ public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/json")
@Operation(
summary = "Convert PDF to JSON",
@ -90,23 +96,37 @@ public class ConvertPdfJsonController {
summary = "Extract PDF metadata for lazy loading",
description =
"Extracts document metadata, fonts, and page dimensions. Caches the document for"
+ " subsequent page requests. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> extractPdfMetadata(
@ModelAttribute PDFFile request, @RequestParam(required = true) String jobId)
+ " subsequent page requests. Returns a server-generated jobId scoped to the"
+ " authenticated user. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<byte[]> extractPdfMetadata(@ModelAttribute PDFFile request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createNullArgumentException("fileInput");
}
byte[] jsonBytes = pdfJsonConversionService.extractDocumentMetadata(inputFile, jobId);
// Generate server-side UUID for job
String baseJobId = UUID.randomUUID().toString();
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
: "document";
String docName = baseName + "_metadata.json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
// Return jobId in response header for client
return ResponseEntity.ok()
.header("X-Job-Id", scopedJobKey)
.contentType(MediaType.APPLICATION_JSON)
.body(jsonBytes);
}
@PostMapping(value = "/pdf/json/partial/{jobId}", consumes = MediaType.APPLICATION_JSON_VALUE)
@ -115,7 +135,8 @@ public class ConvertPdfJsonController {
summary = "Apply incremental edits to a cached PDF",
description =
"Applies edits for the specified pages of a cached PDF and returns an updated PDF."
+ " Requires the PDF to have been previously cached via the PDF to JSON endpoint.")
+ " Requires the PDF to have been previously cached via the PDF to JSON endpoint."
+ " The jobId must be obtained from the metadata extraction endpoint.")
public ResponseEntity<byte[]> exportPartialPdf(
@PathVariable String jobId,
@RequestBody PdfJsonDocument document,
@ -125,6 +146,9 @@ public class ConvertPdfJsonController {
throw ExceptionUtils.createNullArgumentException("document");
}
// Validate job ownership
validateJobAccess(jobId);
byte[] pdfBytes = pdfJsonConversionService.exportUpdatedPages(jobId, document);
String baseName =
@ -143,9 +167,14 @@ public class ConvertPdfJsonController {
summary = "Extract single page from cached PDF",
description =
"Retrieves a single page's content from a previously cached PDF document."
+ " Requires prior call to /pdf/json/metadata. Output:JSON")
+ " Requires prior call to /pdf/json/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractSinglePage(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
@ -156,9 +185,41 @@ public class ConvertPdfJsonController {
summary = "Clear cached PDF document",
description =
"Manually clears a cached PDF document to free up server resources."
+ " Called automatically after 30 minutes.")
+ " Called automatically after 30 minutes. The jobId must belong to the"
+ " authenticated user.")
public ResponseEntity<Void> clearCache(@PathVariable String jobId) {
// Validate job ownership
validateJobAccess(jobId);
pdfJsonConversionService.clearCachedDocument(jobId);
return ResponseEntity.ok().build();
}
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param jobId the job identifier to validate
* @throws SecurityException if current user does not own the job
*/
private void validateJobAccess(String jobId) {
if (jobOwnershipService != null) {
jobOwnershipService.validateJobAccess(jobId);
}
// If jobOwnershipService is null (security disabled), allow all access
}
}

View File

@ -1672,6 +1672,93 @@ public class PdfJsonConversionService {
}
}
/**
* Fuzzy match a font name against Standard14 fonts as a last resort. Handles common variations
* like "TimesNewRoman" "Times-Roman", "Arial" "Helvetica", etc.
*
* @param baseName the font base name to match
* @return matched Standard14 font, or null if no reasonable match found
*/
private Standard14Fonts.FontName fuzzyMatchStandard14(String baseName) {
if (baseName == null || baseName.isBlank()) {
return null;
}
// Normalize: lowercase, remove spaces/hyphens/underscores, strip prefix (ABCD+FontName)
String normalized = baseName.trim();
int plusIndex = normalized.indexOf('+');
if (plusIndex >= 0 && plusIndex < normalized.length() - 1) {
normalized = normalized.substring(plusIndex + 1);
}
normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", "");
// Exact match after normalization
try {
Standard14Fonts.FontName exact = Standard14Fonts.getMappedFontName(baseName);
if (exact != null) {
return exact;
}
} catch (IllegalArgumentException ignored) {
// Not an exact match, continue with fuzzy matching
}
// Times family: Times, TimesRoman, TimesNewRoman, TNR
if (normalized.contains("times") || normalized.equals("tnr")) {
if (normalized.contains("bold") && normalized.contains("italic")) {
return Standard14Fonts.FontName.TIMES_BOLD_ITALIC;
}
if (normalized.contains("bold")) {
return Standard14Fonts.FontName.TIMES_BOLD;
}
if (normalized.contains("italic") || normalized.contains("oblique")) {
return Standard14Fonts.FontName.TIMES_ITALIC;
}
return Standard14Fonts.FontName.TIMES_ROMAN;
}
// Helvetica family: Helvetica, Arial, Swiss
if (normalized.contains("helvetica")
|| normalized.contains("arial")
|| normalized.contains("swiss")) {
if (normalized.contains("bold") && normalized.contains("oblique")) {
return Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE;
}
if (normalized.contains("bold")) {
return Standard14Fonts.FontName.HELVETICA_BOLD;
}
if (normalized.contains("oblique") || normalized.contains("italic")) {
return Standard14Fonts.FontName.HELVETICA_OBLIQUE;
}
return Standard14Fonts.FontName.HELVETICA;
}
// Courier family: Courier, CourierNew, Mono, Monospace
if (normalized.contains("courier") || normalized.contains("mono")) {
if (normalized.contains("bold")
&& (normalized.contains("oblique") || normalized.contains("italic"))) {
return Standard14Fonts.FontName.COURIER_BOLD_OBLIQUE;
}
if (normalized.contains("bold")) {
return Standard14Fonts.FontName.COURIER_BOLD;
}
if (normalized.contains("oblique") || normalized.contains("italic")) {
return Standard14Fonts.FontName.COURIER_OBLIQUE;
}
return Standard14Fonts.FontName.COURIER;
}
// Symbol and ZapfDingbats (less common)
if (normalized.contains("symbol")) {
return Standard14Fonts.FontName.SYMBOL;
}
if (normalized.contains("zapf") || normalized.contains("dingbat")) {
return Standard14Fonts.FontName.ZAPF_DINGBATS;
}
// No reasonable match found
return null;
}
private List<PdfJsonPage> extractPages(
PDDocument document,
Map<Integer, List<PdfJsonTextElement>> textByPage,
@ -2408,15 +2495,18 @@ public class PdfJsonConversionService {
runFontModel =
resolveFontModel(runFontLookup, pageNumber, run.fontId());
}
// Check if this is a normalized Type3 font (has Type3 metadata but is not PDType3Font)
boolean isNormalizedType3 = !(run.font() instanceof PDType3Font)
// Check if this is a normalized Type3 font (has Type3 metadata but is
// not PDType3Font)
boolean isNormalizedType3 =
!(run.font() instanceof PDType3Font)
&& runFontModel != null
&& runFontModel.getType3Glyphs() != null
&& !runFontModel.getType3Glyphs().isEmpty();
if (isNormalizedType3) {
// For normalized Type3 fonts, use original text directly
// The font has proper Unicode mappings, so PDFBox can encode it correctly
// The font has proper Unicode mappings, so PDFBox can encode it
// correctly
contentStream.showText(run.text());
} else {
// For actual Type3 fonts and other fonts, encode manually
@ -3149,7 +3239,8 @@ public class PdfJsonConversionService {
PDFont font, PdfJsonFont fontModel, String text, List<Integer> rawCharCodes)
throws IOException {
boolean isType3Font = font instanceof PDType3Font;
boolean hasType3Metadata = fontModel != null
boolean hasType3Metadata =
fontModel != null
&& fontModel.getType3Glyphs() != null
&& !fontModel.getType3Glyphs().isEmpty();
@ -3162,20 +3253,25 @@ public class PdfJsonConversionService {
// NOTE: Do NOT sanitize encoded bytes for normalized Type3 fonts
// Multi-byte encodings (UTF-16BE, CID fonts) have null bytes that are essential
// Removing them corrupts the byte boundaries and produces garbled text
log.info("[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
log.info(
"[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
text.length() > 20 ? text.substring(0, 20) + "..." : text,
fontModel.getId(),
encoded != null ? encoded.length : 0);
if (encoded != null && encoded.length > 0) {
log.info("[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
log.info(
"[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
fontModel.getId());
return encoded;
}
log.info("[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
log.info(
"[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
fontModel.getId());
} catch (IOException | IllegalArgumentException ex) {
log.info("[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
fontModel.getId(), ex.getMessage());
log.info(
"[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
fontModel.getId(),
ex.getMessage());
}
// If standard encoding failed, fall through to Type3 glyph mapping (for subset fonts)
// or return null to trigger fallback font
@ -3643,6 +3739,19 @@ public class PdfJsonConversionService {
}
}
// Last resort: Fuzzy match baseName against Standard14 fonts
Standard14Fonts.FontName fuzzyMatch = fuzzyMatchStandard14(fontModel.getBaseName());
if (fuzzyMatch != null) {
log.info(
"Fuzzy-matched font {} (baseName: {}) to Standard14 font {}",
fontModel.getId(),
fontModel.getBaseName(),
fuzzyMatch.getName());
PDFont font = new PDType1Font(fuzzyMatch);
applyAdditionalFontMetadata(document, font, fontModel);
return font;
}
PDFont fallback = fallbackFontService.loadFallbackPdfFont(document);
applyAdditionalFontMetadata(document, fallback, fontModel);
return fallback;
@ -3662,7 +3771,8 @@ public class PdfJsonConversionService {
}
for (FontByteSource source : candidates) {
PDFont font =
loadFontFromSource(document, fontModel, source, originalFormat, true, true, true);
loadFontFromSource(
document, fontModel, source, originalFormat, true, true, true);
if (font != null) {
type3NormalizedFontCache.put(fontModel.getUid(), font);
log.info(
@ -3682,7 +3792,8 @@ public class PdfJsonConversionService {
throws IOException {
for (FontByteSource source : candidates) {
PDFont font =
loadFontFromSource(document, fontModel, source, originalFormat, false, false, false);
loadFontFromSource(
document, fontModel, source, originalFormat, false, false, false);
if (font != null) {
return font;
}
@ -3737,8 +3848,10 @@ public class PdfJsonConversionService {
// so all glyphs are available for editing
boolean willBeSubset = !originLabel.contains("type3-library");
if (!willBeSubset) {
log.info("[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
fontModel.getId(), originLabel);
log.info(
"[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
fontModel.getId(),
originLabel);
}
PDFont font = PDType0Font.load(document, stream, willBeSubset);
if (!skipMetadata) {

View File

@ -0,0 +1,105 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service to manage job ownership and access control for PDF JSON operations. When security is
* enabled, jobs are scoped to authenticated users. When security is disabled, jobs are globally
* accessible.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "true", matchIfMissing = false)
public class JobOwnershipServiceImpl
implements stirling.software.common.service.JobOwnershipService {
/**
* Get the current authenticated user's identifier. Returns empty if no user is authenticated.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
public Optional<String> getCurrentUserId() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal())) {
String username = authentication.getName();
log.debug("Current authenticated user: {}", username);
return Optional.of(username);
}
} catch (Exception e) {
log.warn("Failed to get current user from security context: {}", e.getMessage());
}
return Optional.empty();
}
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
public String createScopedJobKey(String jobId) {
Optional<String> userId = getCurrentUserId();
if (userId.isPresent()) {
String scopedKey = userId.get() + ":" + jobId;
log.debug("Created scoped job key: {}", scopedKey);
return scopedKey;
}
log.debug("No user authenticated, using unsecured job key: {}", jobId);
return jobId;
}
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
public boolean validateJobAccess(String scopedJobKey) {
Optional<String> userId = getCurrentUserId();
// If no user authenticated, allow access (backwards compatibility)
if (userId.isEmpty()) {
log.debug("No authentication required, allowing access to job: {}", scopedJobKey);
return true;
}
// Check if job key starts with current user's ID
String userPrefix = userId.get() + ":";
if (!scopedJobKey.startsWith(userPrefix)) {
log.warn(
"Access denied: User {} attempted to access job key {} which they don't own",
userId.get(),
scopedJobKey);
throw new SecurityException(
"Access denied: You do not have permission to access this job");
}
log.debug("Access granted: User {} owns job {}", userId.get(), scopedJobKey);
return true;
}
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
public String extractJobId(String scopedJobKey) {
int colonIndex = scopedJobKey.indexOf(':');
if (colonIndex > 0) {
return scopedJobKey.substring(colonIndex + 1);
}
return scopedJobKey;
}
}

View File

@ -0,0 +1,44 @@
package stirling.software.SPDF.service.pdfjson;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* No-op implementation of job ownership service when security is disabled. All jobs are globally
* accessible without authentication.
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "security.enable-login", havingValue = "false", matchIfMissing = true)
public class NoOpJobOwnershipService
implements stirling.software.common.service.JobOwnershipService {
@Override
public Optional<String> getCurrentUserId() {
// No authentication when security is disabled
return Optional.empty();
}
@Override
public String createScopedJobKey(String jobId) {
// Jobs are not scoped to users when security is disabled
return jobId;
}
@Override
public boolean validateJobAccess(String scopedJobKey) {
// All jobs are accessible when security is disabled
log.trace("Security disabled, allowing access to job: {}", scopedJobKey);
return true;
}
@Override
public String extractJobId(String scopedJobKey) {
// No user prefix when security is disabled
return scopedJobKey;
}
}