Add ServiceOnly annotation.

This commit is contained in:
Andrew Lalis 2023-04-07 10:53:17 +02:00
parent 52be976286
commit ae8595db07
15 changed files with 206 additions and 35 deletions

View File

@ -0,0 +1,39 @@
package nl.andrewlalis.gymboard_api.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.lang.reflect.Method;
/**
* An interceptor that checks that requests to endpoints annotated with
* {@link ServiceOnly} have a valid service secret header value.
*/
@Component
public class ServiceAccessInterceptor implements HandlerInterceptor {
public static final String HEADER_NAME = "X-Gymboard-Service-Secret";
@Value("${app.service-secret}")
private String serviceSecret;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Method handlerMethod = ((HandlerMethod) handler).getMethod();
Class<?> handlerClass = handlerMethod.getDeclaringClass();
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
if (methodAnnotation != null || classAnnotation != null) {
String secret = request.getHeader(HEADER_NAME);
if (secret == null || !secret.trim().equals(serviceSecret)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,16 @@
package nl.andrewlalis.gymboard_api.config;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation that can be applied to a controller or controller method to
* restrict access to only requests from another service that provide a
* legitimate service secret.
* @see ServiceAccessInterceptor
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceOnly {}

View File

@ -22,9 +22,11 @@ public class WebComponents {
@Value("${app.cdn-origin}")
private String cdnOrigin;
@Value("${app.cdn-secret}")
private String cdnSecret;
@Bean
public CdnClient cdnClient() {
return new CdnClient(cdnOrigin);
return new CdnClient(cdnOrigin, cdnSecret);
}
}

View File

@ -1,6 +1,7 @@
package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
import com.fasterxml.jackson.databind.ObjectMapper;
import nl.andrewlalis.gymboard_api.config.ServiceAccessInterceptor;
import java.io.IOException;
import java.net.URI;
@ -13,17 +14,19 @@ import java.time.Duration;
public class CdnClient {
private final HttpClient httpClient;
private final String baseUrl;
private final String cdnSecret;
private final ObjectMapper objectMapper;
public final UploadsClient uploads;
public final FilesClient files;
public CdnClient(String baseUrl) {
public CdnClient(String baseUrl, String cdnSecret) {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
this.baseUrl = baseUrl;
this.cdnSecret = cdnSecret;
this.objectMapper = new ObjectMapper();
this.uploads = new UploadsClient(this);
this.files = new FilesClient(this);
@ -32,6 +35,7 @@ public class CdnClient {
public <T> T get(String urlPath, Class<T> responseType) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
.GET()
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
.build();
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
@ -48,6 +52,7 @@ public class CdnClient {
.POST(HttpRequest.BodyPublishers.ofFile(filePath))
.header("Content-Type", contentType)
.header("X-Gymboard-Filename", filePath.getFileName().toString())
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
.build();
HttpResponse<String> response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), responseType);
@ -56,6 +61,7 @@ public class CdnClient {
public void post(String urlPath) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
.POST(HttpRequest.BodyPublishers.noBody())
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
.build();
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 200) {
@ -65,7 +71,9 @@ public class CdnClient {
public void delete(String urlPath) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + urlPath))
.DELETE().build();
.DELETE()
.header(ServiceAccessInterceptor.HEADER_NAME, cdnSecret)
.build();
HttpResponse<Void> response = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() >= 400) {
throw new IOException("Request failed with code " + response.statusCode());

View File

@ -1,5 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.submission.controller;
import nl.andrewlalis.gymboard_api.config.ServiceOnly;
import nl.andrewlalis.gymboard_api.domains.submission.dto.SubmissionResponse;
import nl.andrewlalis.gymboard_api.domains.api.dto.VideoProcessingCompletePayload;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
@ -28,9 +29,8 @@ public class SubmissionController {
return ResponseEntity.noContent().build();
}
@PostMapping(path = "/video-processing-complete")
@PostMapping(path = "/video-processing-complete") @ServiceOnly
public ResponseEntity<Void> handleVideoProcessingComplete(@RequestBody VideoProcessingCompletePayload taskStatus) {
// TODO: Validate that the request came ONLY from the CDN service.
submissionService.handleVideoProcessingComplete(taskStatus);
return ResponseEntity.noContent().build();
}

View File

@ -15,7 +15,6 @@ import nl.andrewlalis.gymboard_api.domains.submission.model.SubmissionProperties
import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Component;
@ -37,16 +36,15 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
private final ExerciseRepository exerciseRepository;
private final SubmissionRepository submissionRepository;
private final ULID ulid;
private final CdnClient cdnClient;
@Value("${app.cdn-origin}")
private String cdnOrigin;
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, ULID ulid) {
public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, SubmissionRepository submissionRepository, ULID ulid, CdnClient cdnClient) {
this.gymRepository = gymRepository;
this.userRepository = userRepository;
this.exerciseRepository = exerciseRepository;
this.submissionRepository = submissionRepository;
this.ulid = ulid;
this.cdnClient = cdnClient;
}
@Override
@ -143,8 +141,6 @@ public class SampleSubmissionGenerator implements SampleDataGenerator {
* @throws Exception If an error occurs.
*/
private Map<Long, Pair<String, String>> generateUploads() throws Exception {
final CdnClient cdnClient = new CdnClient(cdnOrigin);
List<Long> taskIds = new ArrayList<>();
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4"));
taskIds.add(cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4"));

View File

@ -15,7 +15,9 @@ spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.timeout=10000
app.auth.private-key-location=./private_key.der
app.service-secret=testing
app.web-origin=http://localhost:9000
app.cdn-origin=http://localhost:8082
app.cdn-secret=testing
#logging.level.root=DEBUG

View File

@ -14,6 +14,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.concurrent.Executor;
@ -21,12 +23,23 @@ import java.util.concurrent.Executors;
@Configuration
@EnableScheduling
public class Config {
public class Config implements WebMvcConfigurer {
@Value("${app.web-origin}")
private String webOrigin;
@Value("${app.api-origin}")
private String apiOrigin;
private final ServiceAccessInterceptor serviceAccessInterceptor;
public Config(ServiceAccessInterceptor serviceAccessInterceptor) {
this.serviceAccessInterceptor = serviceAccessInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(serviceAccessInterceptor);
}
@Bean
public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

View File

@ -0,0 +1,39 @@
package nl.andrewlalis.gymboardcdn;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.lang.reflect.Method;
/**
* An interceptor that checks that requests to endpoints annotated with
* {@link ServiceOnly} have a valid service secret header value.
*/
@Component
public class ServiceAccessInterceptor implements HandlerInterceptor {
private static final String HEADER_NAME = "X-Gymboard-Service-Secret";
@Value("${app.service-secret}")
private String serviceSecret;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Method handlerMethod = ((HandlerMethod) handler).getMethod();
Class<?> handlerClass = handlerMethod.getDeclaringClass();
ServiceOnly methodAnnotation = handlerMethod.getAnnotation(ServiceOnly.class);
ServiceOnly classAnnotation = handlerClass.getAnnotation(ServiceOnly.class);
if (methodAnnotation != null || classAnnotation != null) {
String secret = request.getHeader(HEADER_NAME);
if (secret == null || !secret.trim().equals(serviceSecret)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,16 @@
package nl.andrewlalis.gymboardcdn;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation that can be applied to a controller or controller method to
* restrict access to only requests from another service that provide a
* legitimate service secret.
* @see ServiceAccessInterceptor
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceOnly {}

View File

@ -1,6 +1,7 @@
package nl.andrewlalis.gymboardcdn.files;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.ServiceOnly;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@ -37,9 +38,8 @@ public class FileController {
}
}
@DeleteMapping(path = "/files/{id}")
@DeleteMapping(path = "/files/{id}") @ServiceOnly
public void deleteFile(@PathVariable String id) {
// TODO: Secure this so only API can access it!
try {
fileStorageService.delete(id);
} catch (IOException e) {

View File

@ -1,6 +1,7 @@
package nl.andrewlalis.gymboardcdn.uploads.api;
import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboardcdn.ServiceOnly;
import nl.andrewlalis.gymboardcdn.uploads.service.UploadService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -20,7 +21,7 @@ public class UploadController {
return uploadService.processableVideoUpload(request);
}
@PostMapping(path = "/uploads/video/{taskId}/start")
@PostMapping(path = "/uploads/video/{taskId}/start") @ServiceOnly
public void startVideoProcessing(@PathVariable long taskId) {
uploadService.startVideoProcessing(taskId);
}

View File

@ -0,0 +1,12 @@
package nl.andrewlalis.gymboardcdn.uploads.model;
public record VideoProcessingTaskStatusUpdate(
long taskId,
String status,
String videoFileId,
String thumbnailFileId
) {
public VideoProcessingTaskStatusUpdate(VideoProcessingTask task) {
this(task.getId(), task.getStatus().name(), task.getVideoFileId(), task.getThumbnailFileId());
}
}

View File

@ -2,11 +2,11 @@ package nl.andrewlalis.gymboardcdn.uploads.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import nl.andrewlalis.gymboardcdn.files.FileMetadata;
import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskStatusUpdate;
import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator;
import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor;
import org.slf4j.Logger;
@ -42,6 +42,9 @@ public class VideoProcessingService {
@Value("${app.api-origin}")
private String apiOrigin;
@Value("${app.api-secret}")
private String apiSecret;
public VideoProcessingService(Executor videoProcessingExecutor,
VideoProcessingTaskRepository taskRepo,
FileStorageService fileStorageService,
@ -80,15 +83,18 @@ public class VideoProcessingService {
for (var task : oldTasks) {
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
log.info("Deleting completed task {}.", task.getId());
deleteAllTaskFiles(task);
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
log.info("Deleting failed task {}.", task.getId());
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
log.info("Task {} was in progress for too long; deleting.", task.getId());
deleteAllTaskFiles(task);
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
log.info("Task {} was waiting for too long; deleting.", task.getId());
deleteAllTaskFiles(task);
taskRepo.delete(task);
}
}
@ -156,32 +162,21 @@ public class VideoProcessingService {
log.error("Failed to copy processed video to final storage location.", e);
updateTask(task, VideoProcessingTask.Status.FAILED);
} finally {
try {
fileStorageService.delete(task.getUploadFileId());
Files.deleteIfExists(rawUploadFile);
Files.deleteIfExists(videoFile);
Files.deleteIfExists(thumbnailFile);
} catch (IOException e) {
log.error("Couldn't delete temporary output files for uploaded video {}", uploadFile);
e.printStackTrace();
}
deleteAllTaskFiles(task);
}
}
/**
* Sends an update message to the Gymboard API when a task finishes its
* processing.
* processing. Note that Gymboard API will also eventually poll the CDN's
* own API to get task status if we fail to send it, so there's some
* redundancy built-in.
* @param task The task to send.
*/
private void sendTaskCompleteToApi(VideoProcessingTask task) {
ObjectNode obj = objectMapper.createObjectNode();
obj.put("taskId", task.getId());
obj.put("status", task.getStatus().name());
obj.put("videoFileId", task.getVideoFileId());
obj.put("thumbnailFileId", task.getThumbnailFileId());
String json;
try {
json = objectMapper.writeValueAsString(obj);
json = objectMapper.writeValueAsString(new VideoProcessingTaskStatusUpdate(task));
} catch (JsonProcessingException e) {
log.error("JSON error while sending task data to API for task " + task.getId(), e);
return;
@ -189,6 +184,7 @@ public class VideoProcessingService {
HttpClient httpClient = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder(URI.create(apiOrigin + "/submissions/video-processing-complete"))
.header("Content-Type", "application/json")
.header("X-Gymboard-Service-Secret", apiSecret)
.timeout(Duration.ofSeconds(3))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
@ -201,4 +197,33 @@ public class VideoProcessingService {
log.error("Failed to send HTTP request to API.", e);
}
}
/**
* Helper function to delete all temporary files related to a task's
* processing operations. If the task is FAILED, then files are kept for
* debugging purposes.
* @param task The task to delete files for.
*/
private void deleteAllTaskFiles(VideoProcessingTask task) {
if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
log.warn("Retaining files for failed task {}, upload id {}.", task.getId(), task.getUploadFileId());
return;
}
Path dir = fileStorageService.getStoragePathForFile(task.getUploadFileId()).getParent();
try (var s = Files.list(dir)) {
var files = s.toList();
for (var file : files) {
String filename = file.getFileName().toString().strip();
if (Files.isRegularFile(file) && filename.startsWith(task.getUploadFileId())) {
try {
Files.delete(file);
} catch (IOException e) {
log.error("Failed to delete file " + file + " related to task " + task.getId(), e);
}
}
}
} catch (IOException e) {
log.error("Failed to list files in " + dir + " when deleting files for task " + task.getId(), e);
}
}
}

View File

@ -6,7 +6,9 @@ spring.jpa.hibernate.ddl-auto=update
server.port=8082
# A secret header token that other services must provide to use service-only endpoints.
app.service-secret=testing
app.web-origin=http://localhost:9000
app.api-origin=http://localhost:8080
app.files.storage-dir=./cdn-files/
app.files.temp-dir=./cdn-files/tmp/
app.api-secret=testing