Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ead2332
Add method to generate presigned GET URL in MinioStorageService
Linsss123 Apr 22, 2026
87b179d
Add PresignedUrlResponseDTO to encapsulate presigned URL and TTL
Linsss123 Apr 22, 2026
3955fb6
Add presign endpoint to AttachmentDownloadController for generating t…
Linsss123 Apr 22, 2026
abba8ae
Require authentication for presign POST endpoint in SecurityConfig
Linsss123 Apr 22, 2026
b39d0a0
Add role-based access control for presign endpoint in AttachmentDownl…
Linsss123 Apr 22, 2026
514a100
Log audit events for presign access and implement content-disposition…
Linsss123 Apr 22, 2026
42769fc
Add AuthorizationService to handle role-based access checks for attac…
Linsss123 Apr 22, 2026
1db07d1
Add audit logging for file download requests and enforce role-based a…
Linsss123 Apr 22, 2026
43f48c1
Add audit logging for file download requests and enforce role-based a…
Linsss123 Apr 22, 2026
453843c
Enforce TTL validation and configuration for presigned URLs in Attach…
Linsss123 Apr 22, 2026
ab0c3ce
Add unit test for TTL validation in AttachmentDownloadController and …
Linsss123 Apr 22, 2026
8cbeba3
Refactor AttachmentDownloadControllerTest to use standalone setup and…
Linsss123 Apr 22, 2026
83dfbea
Add comprehensive unit tests for presign endpoint covering success, a…
Linsss123 Apr 22, 2026
738f15d
Add unit tests for AuditService and extend AttachmentDownloadControll…
Linsss123 Apr 22, 2026
91a0b56
Fix typo in application.properties comment
Linsss123 Apr 22, 2026
7dc1bd3
Add unit tests for AttachmentController upload scenarios covering suc…
Linsss123 Apr 22, 2026
92d2140
Expand unit tests for AttachmentController and AttachmentDownloadCont…
Linsss123 Apr 22, 2026
c478f3e
Add unit test for presign endpoint to validate storage failure scenar…
Linsss123 Apr 22, 2026
3c972ab
Add unit tests for presign endpoint to validate default TTL behavior …
Linsss123 Apr 22, 2026
2b36dd6
Fix typo in application.properties comment
Linsss123 Apr 23, 2026
9d1fc55
Fix typo in application.properties comment
Linsss123 Apr 23, 2026
74c16f4
Fix typo in application.properties comment
Linsss123 Apr 23, 2026
e9a390c
Remove deprecated presign endpoint JavaDoc in AttachmentDownloadContr…
Linsss123 Apr 23, 2026
a832ec9
Refactor `MinioStorageService` to enhance filename sanitization and i…
Linsss123 Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,32 @@ createdAt = 2026-03-27
```
- Check MinIO Console → your bucket → object is present.

5) Test file download (GET)
- Take the `id` from the upload response above and request:
5) Secure download via presigned URL (Week 2)

- Hämta en presignerad URL (kräver JWT och behörighet):
```bash
curl -H "Authorization: Bearer <JWT>" \
-X POST "http://localhost:8080/api/files/42/presign?ttl=120"
```
- Svar:
```json
{ "url": "http://localhost:9000/alfs-attachments/<objectKey>?X-Amz-...", "expiresInSeconds": 120 }
```
- Öppna URL:en i webbläsare eller via curl för direktnedladdning från MinIO.
- Content-Disposition sätts så att webbläsaren föreslår originalfilnamnet.

6) Direkt download-endpoint (GET)
- Finns kvar för utveckling/test: `GET /api/files/{id}/download`.
- Kräver nu inloggning och samma behörighetsregler som presign.
- Exempel:
```bash
curl -v -o downloaded.pdf "http://localhost:8080/api/files/42/download"
curl -H "Authorization: Bearer <JWT>" -v -o downloaded.pdf "http://localhost:8080/api/files/42/download"
```
- The file should be downloaded as `downloaded.pdf`.
- Rekommenderad väg för klienter är presigned URL-flödet ovan.

Notes
- No authentication is enforced on these endpoints yet (Week 1 scope).
- Upload (POST /api/files/upload) är öppen i dev enligt Week 1 scope; presign och download kräver JWT och behörighet.
- Behörighetsregler (Week 2): ADMIN/INVESTIGATOR alltid; REPORTER endast om ägare av ärendet.
- Ensure a Ticket with the provided `ticketId` exists in the database before uploading.


Expand Down
9 changes: 5 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,18 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Explicitly include Spring Boot test autoconfigure to provide @WebMvcTest, @AutoConfigureMockMvc, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<scope>test</scope>
</dependency>
<dependency>
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/org/example/alfs/config/S3Properties.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public class S3Properties {
private String region;
private boolean secure;

// Maximal giltighetstid (sekunder) för presignerade URL:er. Standard: 24h.
private int presignMaxTtlSeconds = 86400;

public String getEndpoint() {
return endpoint;
}
Expand Down Expand Up @@ -66,4 +69,12 @@ public boolean isSecure() {
public void setSecure(boolean secure) {
this.secure = secure;
}

public int getPresignMaxTtlSeconds() {
return presignMaxTtlSeconds;
}

public void setPresignMaxTtlSeconds(int presignMaxTtlSeconds) {
this.presignMaxTtlSeconds = presignMaxTtlSeconds;
}
}
2 changes: 2 additions & 0 deletions src/main/java/org/example/alfs/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
//allow access to endpoints during development
.requestMatchers("/tickets/previewTicket").permitAll()
.requestMatchers(HttpMethod.POST, "/api/files/upload").permitAll()
// presign ska kräva inloggning (ingen finmaskig ägarskap ännu)
.requestMatchers(HttpMethod.POST, "/api/files/*/presign").authenticated()
.requestMatchers("/css/**", "/js/**", "/images/**", "/static/**").permitAll()
.requestMatchers("/login", "/login-form").permitAll()
.requestMatchers("/signup", "/signup-form").permitAll()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@

import io.minio.GetObjectResponse;
import org.example.alfs.entities.Attachment;
import org.example.alfs.dto.attachment.PresignedUrlResponseDTO;
import org.example.alfs.repositories.AttachmentRepository;
import org.example.alfs.security.SecurityUtils;
import org.example.alfs.entities.User;
import org.example.alfs.services.storage.MinioStorageService;
import org.example.alfs.services.AuditService;
import org.example.alfs.enums.AuditAction;
import org.example.alfs.services.AuthorizationService;
import org.example.alfs.config.S3Properties;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
Expand All @@ -14,6 +21,8 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

Expand All @@ -25,18 +34,54 @@ public class AttachmentDownloadController {

private final AttachmentRepository attachmentRepository;
private final MinioStorageService storageService;
private final SecurityUtils securityUtils;
private final AuditService auditService;
private final AuthorizationService authorizationService;
private final S3Properties s3Properties;

public AttachmentDownloadController(AttachmentRepository attachmentRepository,
MinioStorageService storageService) {
MinioStorageService storageService,
SecurityUtils securityUtils,
AuditService auditService,
AuthorizationService authorizationService,
S3Properties s3Properties) {
this.attachmentRepository = attachmentRepository;
this.storageService = storageService;
this.securityUtils = securityUtils;
this.auditService = auditService;
this.authorizationService = authorizationService;
this.s3Properties = s3Properties;
}

@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
Attachment att = attachmentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id));

// Säkerställ att den som laddar ner har rättigheter (samma regler som för presign)
User current = securityUtils.getCurrentUser();
if (!authorizationService.canAccessAttachment(current, att)) {
auditService.log(
AuditAction.ACCESS_DENIED,
"attachment",
null,
"download denied: attachmentId=" + att.getId(),
att.getTicket(),
current
);
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment");
}

// Audit: registrera nedladdningsförsök
auditService.log(
AuditAction.FILE_DOWNLOAD_REQUESTED,
"attachment",
null,
"download requested: attachmentId=" + att.getId(),
att.getTicket(),
current
);

final GetObjectResponse object;
try {
object = storageService.download(att.getS3Key());
Expand Down Expand Up @@ -68,4 +113,61 @@ public ResponseEntity<Resource> download(@PathVariable Long id) {

return builder.body(resource);
}

@PostMapping("/{id}/presign")
public ResponseEntity<PresignedUrlResponseDTO> presign(@PathVariable Long id,
@RequestParam(name = "ttl", required = false) Integer ttlSeconds) {
Attachment att = attachmentRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id));

// Enkel behörighetskontroll (steg 2, vecka 2):
// - ADMIN och INVESTIGATOR får alltid presigna
// - REPORTER får endast presigna om hen äger ärendet (ticket.reporter)
User current = securityUtils.getCurrentUser();
if (!authorizationService.canAccessAttachment(current, att)) {
// Audit: access denied to presign for this attachment
auditService.log(
AuditAction.ACCESS_DENIED,
"attachment",
null,
"presign denied: attachmentId=" + att.getId(),
att.getTicket(),
current
);
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not allowed to access this attachment");
}

// Validera och begränsa TTL enligt konfiguration
int maxTtl = Math.max(1, s3Properties.getPresignMaxTtlSeconds());
int defaultTtl = Math.min(120, maxTtl);

int ttl = (ttlSeconds == null ? defaultTtl : ttlSeconds);
if (ttl <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ttl must be a positive integer (seconds)");
}
if (ttl > maxTtl) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"ttl exceeds max allowed (" + maxTtl + " seconds). Configure storage.s3.presign-max-ttl-seconds if needed.");
}

final String url;
try {
// Använd variant som sätter Content-Disposition till originalfilnamnet
url = storageService.generatePresignedGetUrlWithContentDisposition(att.getS3Key(), ttl, att.getFileName());
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Failed to create presigned URL", ex);
}

// Audit: presigned URL issued
auditService.log(
AuditAction.FILE_PRESIGNED,
"attachment",
null,
"presign issued: attachmentId=" + att.getId() + ", ttl=" + ttl,
att.getTicket(),
current
);

return ResponseEntity.ok(new PresignedUrlResponseDTO(url, ttl));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.example.alfs.dto.attachment;

/**
* Litet svar-DTO för att returnera en presignerad URL och dess TTL i sekunder.
* Används av kommande presign-endpoint.
*/
public class PresignedUrlResponseDTO {
private final String url;
private final int expiresInSeconds;

public PresignedUrlResponseDTO(String url, int expiresInSeconds) {
this.url = url;
this.expiresInSeconds = expiresInSeconds;
}

public String getUrl() {
return url;
}

public int getExpiresInSeconds() {
return expiresInSeconds;
}
}
5 changes: 4 additions & 1 deletion src/main/java/org/example/alfs/enums/AuditAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ public enum AuditAction {
ASSIGNED,
UNASSIGNED,
COMMENT_ADDED,
ATTACHMENT_ADDED
ATTACHMENT_ADDED,
FILE_DOWNLOAD_REQUESTED,
FILE_PRESIGNED,
ACCESS_DENIED
}
32 changes: 32 additions & 0 deletions src/main/java/org/example/alfs/services/AuthorizationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.example.alfs.services;

import org.example.alfs.entities.Attachment;
import org.example.alfs.entities.User;
import org.example.alfs.enums.Role;
import org.springframework.stereotype.Service;

@Service
public class AuthorizationService {

/**
* Kontrollerar om användaren får åtkomst till en bilaga kopplad till ett ärende.
* Regler (vecka 2):
* - ADMIN och INVESTIGATOR: alltid tillåtna
* - REPORTER: endast om användaren är reporter för ticketen
*/
public boolean canAccessAttachment(User user, Attachment attachment) {
if (user == null || attachment == null) return false;

Role role = user.getRole();
if (role == Role.ADMIN || role == Role.INVESTIGATOR) return true;

if (role == Role.REPORTER) {
var ticket = attachment.getTicket();
return ticket != null
&& ticket.getReporter() != null
&& ticket.getReporter().getId().equals(user.getId());
}

return false;
}
}
Loading
Loading