From fe5bc54e92353970f72ce93c729530e7aadaab82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Thu, 27 Nov 2025 23:31:03 +0100 Subject: [PATCH] Spring Boot Setup, Start of ActivitiPub --- CLAUDE.md | 104 ++++++++- pom.xml | 15 ++ .../operaton/fitpub/FitPubApplication.java | 36 ++++ .../fitpub/config/SecurityConfig.java | 117 ++++++++++ .../config/TestcontainersConfiguration.java | 36 ++++ .../fitpub/controller/ActivityController.java | 172 +++++++++++++++ .../controller/ActivityPubController.java | 169 +++++++++++++++ .../controller/WebFingerController.java | 98 +++++++++ .../fitpub/model/activitypub/Actor.java | 102 +++++++++ .../model/activitypub/OrderedCollection.java | 60 ++++++ .../model/activitypub/WebFingerResponse.java | 65 ++++++ .../model/dto/ActivityUpdateRequest.java | 29 +++ .../model/dto/ActivityUploadRequest.java | 29 +++ .../fitpub/model/entity/Activity.java | 2 +- .../operaton/fitpub/model/entity/User.java | 94 +++++++++ .../fitpub/repository/UserRepository.java | 60 ++++++ .../security/HttpSignatureValidator.java | 199 ++++++++++++++++++ .../security/JwtAuthenticationFilter.java | 73 +++++++ .../fitpub/security/JwtTokenProvider.java | 113 ++++++++++ .../security/UserDetailsServiceImpl.java | 57 +++++ src/main/resources/application.yml | 74 +++++++ 21 files changed, 1695 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/FitPubApplication.java create mode 100644 src/main/java/org/operaton/fitpub/config/SecurityConfig.java create mode 100644 src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java create mode 100644 src/main/java/org/operaton/fitpub/controller/ActivityController.java create mode 100644 src/main/java/org/operaton/fitpub/controller/ActivityPubController.java create mode 100644 src/main/java/org/operaton/fitpub/controller/WebFingerController.java create mode 100644 src/main/java/org/operaton/fitpub/model/activitypub/Actor.java create mode 100644 src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java create mode 100644 src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResponse.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/ActivityUpdateRequest.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java create mode 100644 src/main/java/org/operaton/fitpub/model/entity/User.java create mode 100644 src/main/java/org/operaton/fitpub/repository/UserRepository.java create mode 100644 src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java create mode 100644 src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java create mode 100644 src/main/java/org/operaton/fitpub/security/UserDetailsServiceImpl.java create mode 100644 src/main/resources/application.yml diff --git a/CLAUDE.md b/CLAUDE.md index d46d152..f056272 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -507,14 +507,102 @@ For ActivityPub federated posts and thumbnails: ## Development Roadmap ### Phase 1: MVP (Minimum Viable Product) -- [ ] FIT file upload and parsing -- [ ] Basic activity storage and display -- [ ] Interactive map rendering with Leaflet -- [ ] User registration and authentication -- [ ] ActivityPub actor profile implementation -- [ ] WebFinger support -- [ ] Basic federation (Create, Follow, Accept activities) -- [ ] Public timeline view + +**System Component 1: FIT File Processing Module** ✅ +- [x] FIT file upload and parsing (FitParser) +- [x] FIT file validation (FitFileValidator) +- [x] Activity entity with JSONB track points and simplified LineString +- [x] Activity metrics extraction and storage +- [x] Track simplification using Douglas-Peucker algorithm +- [x] FIT file service with comprehensive tests +- [x] Integration test with real FIT file + +**User Management & Security** +- [x] User entity with ActivityPub keys +- [ ] UserRepository with custom queries +- [ ] Password hashing with BCrypt +- [ ] JWT token provider for session management +- [ ] HTTP Signature validator for ActivityPub federation +- [ ] UserDetailsService implementation +- [ ] Security configuration (Spring Security) +- [ ] User registration endpoint +- [ ] Login endpoint with JWT response + +**Application Infrastructure** +- [ ] Main application class (FitPubApplication.java) +- [ ] Application configuration (application.yml) +- [ ] Database configuration (PostgreSQL + PostGIS) +- [ ] CORS configuration for frontend +- [ ] Exception handling (global error handlers) +- [ ] Logging configuration +- [ ] Profile-specific configs (dev, prod) + +**Activity REST API** +- [ ] POST /api/activities/upload - Upload FIT file +- [ ] GET /api/activities/{id} - Get activity details +- [ ] GET /api/activities - List user's activities (paginated) +- [ ] PUT /api/activities/{id} - Update activity metadata +- [ ] DELETE /api/activities/{id} - Delete activity +- [ ] Activity DTOs for API responses +- [ ] Controller tests + +**ActivityPub Actor Profile** +- [ ] Actor model classes (Person, PublicKey) +- [ ] GET /users/{username} - Actor profile endpoint +- [ ] Actor JSON-LD serialization with @context +- [ ] Public key embedding in actor profile +- [ ] Profile metadata (name, bio, avatar) + +**WebFinger Support** +- [ ] WebFinger controller +- [ ] GET /.well-known/webfinger - User discovery +- [ ] WebFinger response DTO +- [ ] Account identifier parsing (acct:user@domain) + +**ActivityPub Collections** +- [ ] GET /users/{username}/inbox - Inbox endpoint +- [ ] GET /users/{username}/outbox - Outbox endpoint +- [ ] GET /users/{username}/followers - Followers collection +- [ ] GET /users/{username}/following - Following collection +- [ ] OrderedCollection model classes +- [ ] Collection pagination support + +**Basic Federation** +- [ ] Federation service for outbound activities +- [ ] HTTP signature signing for outbound requests +- [ ] HTTP signature verification for inbound requests +- [ ] Create activity - Post new workout +- [ ] Follow activity - Remote user follows local user +- [ ] Accept activity - Accept follow requests +- [ ] Follow entity and repository +- [ ] Remote actor entity and repository +- [ ] Inbox processor for incoming activities + +**Public Timeline** +- [ ] Timeline service +- [ ] GET /api/timeline - Federated timeline +- [ ] Merge local and remote activities +- [ ] Timeline filtering and pagination +- [ ] Activity visibility enforcement + +**Database Migrations** +- [ ] Flyway or Liquibase setup +- [ ] Initial schema migration (users table) +- [ ] Activities table migration +- [ ] Activity metrics table migration +- [ ] Follows table migration +- [ ] Remote actors table migration +- [ ] Indexes for performance (user lookups, activity queries) +- [ ] PostGIS spatial indexes + +**Testing & Documentation** +- [ ] Integration tests for REST endpoints +- [ ] Integration tests for ActivityPub federation +- [ ] Integration tests for WebFinger +- [ ] README with setup instructions +- [ ] API documentation (Swagger/OpenAPI) +- [ ] Database setup guide +- [ ] Deployment instructions ### Phase 2: Social Features - [ ] Likes and comments diff --git a/pom.xml b/pom.xml index 6a794bf..d77914a 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,21 @@ org.hibernate.orm hibernate-spatial + + + + org.springframework.boot + spring-boot-testcontainers + + + org.testcontainers + postgresql + + + org.springframework.boot + spring-boot-devtools + true + io.hypersistence hypersistence-utils-hibernate-63 diff --git a/src/main/java/org/operaton/fitpub/FitPubApplication.java b/src/main/java/org/operaton/fitpub/FitPubApplication.java new file mode 100644 index 0000000..54bbee4 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/FitPubApplication.java @@ -0,0 +1,36 @@ +package org.operaton.fitpub; + +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.config.TestcontainersConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.web.client.RestTemplate; + +/** + * Main Spring Boot application class for FitPub. + * FitPub is a federated fitness tracking platform that integrates with the Fediverse + * through the ActivityPub protocol. + */ +@SpringBootApplication +@EnableAsync +@Slf4j +@Import(TestcontainersConfiguration.class) +public class FitPubApplication { + + public static void main(String[] args) { + SpringApplication.run(FitPubApplication.class, args); + log.info("FitPub application started successfully!"); + log.info("Upload your FIT files and share your activities with the Fediverse!"); + } + + /** + * REST template for making HTTP requests to remote ActivityPub servers. + */ + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java new file mode 100644 index 0000000..7fe13f7 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -0,0 +1,117 @@ +package org.operaton.fitpub.config; + +import lombok.RequiredArgsConstructor; +import org.operaton.fitpub.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +/** + * Security configuration for the application. + * Configures JWT-based authentication for REST API and public access for ActivityPub endpoints. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final UserDetailsService userDetailsService; + + /** + * Configures the security filter chain. + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // Disable CSRF for REST API + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + // Public endpoints - ActivityPub federation + .requestMatchers("/.well-known/**").permitAll() + .requestMatchers(HttpMethod.GET, "/users/**").permitAll() + .requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll() + + // Public endpoints - Authentication + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/users/register").permitAll() + + // Protected endpoints - Activities + .requestMatchers("/api/activities/**").authenticated() + + // All other requests require authentication + .anyRequest().authenticated() + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * Configures the password encoder. + * Uses BCrypt with default strength (10 rounds). + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Configures the authentication provider. + */ + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + /** + * Exposes the authentication manager bean. + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + /** + * Configures CORS for frontend access. + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java new file mode 100644 index 0000000..761b4b4 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java @@ -0,0 +1,36 @@ +package org.operaton.fitpub.config; + +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers configuration for development using Spring Boot Dev Services. + * Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode. + * + * This ensures development environment matches production (PostgreSQL + PostGIS). + */ +@Configuration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + /** + * PostgreSQL container with PostGIS extension. + * Uses postgis/postgis image which includes both PostgreSQL and PostGIS. + * + * @ServiceConnection automatically configures spring.datasource.* properties + */ + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.4") + .asCompatibleSubstituteFor("postgres") + ) + .withDatabaseName("fitpub") + .withUsername("fitpub") + .withPassword("fitpub") + .withReuse(true); // Reuse container across runs for faster startup + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java new file mode 100644 index 0000000..0cfc79b --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -0,0 +1,172 @@ +package org.operaton.fitpub.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.ActivityDTO; +import org.operaton.fitpub.model.dto.ActivityUpdateRequest; +import org.operaton.fitpub.model.dto.ActivityUploadRequest; +import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.service.FitFileService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * REST controller for activity management. + * Handles FIT file uploads, activity retrieval, updates, and deletion. + */ +@RestController +@RequestMapping("/api/activities") +@RequiredArgsConstructor +@Slf4j +public class ActivityController { + + private final FitFileService fitFileService; + + /** + * Uploads a FIT file and creates a new activity. + * + * @param file the FIT file + * @param request the upload request with metadata + * @param userDetails the authenticated user + * @return the created activity + */ + @PostMapping("/upload") + public ResponseEntity uploadActivity( + @RequestParam("file") MultipartFile file, + @Valid @ModelAttribute ActivityUploadRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename()); + + // TODO: Get actual user ID from UserDetails + UUID userId = UUID.randomUUID(); // Temporary - will be replaced with actual user lookup + + Activity activity = fitFileService.processFitFile( + file, + userId, + request.getTitle(), + request.getDescription(), + request.getVisibility() + ); + + ActivityDTO dto = ActivityDTO.fromEntity(activity); + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + /** + * Retrieves an activity by ID. + * + * @param id the activity ID + * @param userDetails the authenticated user + * @return the activity + */ + @GetMapping("/{id}") + public ResponseEntity getActivity( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails userDetails + ) { + // TODO: Get actual user ID from UserDetails + UUID userId = UUID.randomUUID(); // Temporary + + Activity activity = fitFileService.getActivity(id, userId); + if (activity == null) { + return ResponseEntity.notFound().build(); + } + + ActivityDTO dto = ActivityDTO.fromEntity(activity); + return ResponseEntity.ok(dto); + } + + /** + * Lists all activities for the authenticated user. + * + * @param userDetails the authenticated user + * @return list of activities + */ + @GetMapping + public ResponseEntity> getUserActivities( + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} retrieving activities", userDetails.getUsername()); + + // TODO: Get actual user ID from UserDetails + UUID userId = UUID.randomUUID(); // Temporary + + List activities = fitFileService.getUserActivities(userId); + List dtos = activities.stream() + .map(ActivityDTO::fromEntity) + .collect(Collectors.toList()); + + return ResponseEntity.ok(dtos); + } + + /** + * Updates activity metadata. + * + * @param id the activity ID + * @param request the update request + * @param userDetails the authenticated user + * @return the updated activity + */ + @PutMapping("/{id}") + public ResponseEntity updateActivity( + @PathVariable UUID id, + @Valid @RequestBody ActivityUpdateRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} updating activity {}", userDetails.getUsername(), id); + + // TODO: Get actual user ID from UserDetails + UUID userId = UUID.randomUUID(); // Temporary + + Activity activity = fitFileService.getActivity(id, userId); + if (activity == null) { + return ResponseEntity.notFound().build(); + } + + // Update fields + activity.setTitle(request.getTitle()); + activity.setDescription(request.getDescription()); + activity.setVisibility(request.getVisibility()); + + // TODO: Add update method to FitFileService + // Activity updated = fitFileService.updateActivity(activity); + + ActivityDTO dto = ActivityDTO.fromEntity(activity); + return ResponseEntity.ok(dto); + } + + /** + * Deletes an activity. + * + * @param id the activity ID + * @param userDetails the authenticated user + * @return no content + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteActivity( + @PathVariable UUID id, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} deleting activity {}", userDetails.getUsername(), id); + + // TODO: Get actual user ID from UserDetails + UUID userId = UUID.randomUUID(); // Temporary + + boolean deleted = fitFileService.deleteActivity(id, userId); + if (!deleted) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java new file mode 100644 index 0000000..7568a15 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java @@ -0,0 +1,169 @@ +package org.operaton.fitpub.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.activitypub.Actor; +import org.operaton.fitpub.model.activitypub.OrderedCollection; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.Optional; + +/** + * ActivityPub protocol controller. + * Implements ActivityPub server-to-server (S2S) protocol endpoints. + * + * Spec: https://www.w3.org/TR/activitypub/ + */ +@RestController +@RequiredArgsConstructor +@Slf4j +public class ActivityPubController { + + private final UserRepository userRepository; + + @Value("${fitpub.base-url}") + private String baseUrl; + + private static final String ACTIVITY_JSON = "application/activity+json"; + private static final String LD_JSON = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + + /** + * Actor profile endpoint. + * Returns the ActivityPub Actor object for a user. + * + * @param username the username + * @return Actor object in JSON-LD format + */ + @GetMapping( + value = "/users/{username}", + produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE} + ) + public ResponseEntity getActor(@PathVariable String username) { + log.debug("ActivityPub actor request for user: {}", username); + + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + log.warn("User not found for ActivityPub request: {}", username); + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + Actor actor = Actor.fromUser(user, baseUrl); + + return ResponseEntity.ok(actor); + } + + /** + * Inbox endpoint for receiving ActivityPub activities. + * POST /users/{username}/inbox + * + * @param username the username + * @param activity the incoming activity + * @return accepted response + */ + @PostMapping( + value = "/users/{username}/inbox", + consumes = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE} + ) + public ResponseEntity inbox( + @PathVariable String username, + @RequestBody Map activity, + @RequestHeader(value = "Signature", required = false) String signature + ) { + log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type")); + + // TODO: Validate HTTP signature + // TODO: Process activity based on type (Follow, Like, Create, etc.) + + // For MVP, just accept all activities + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); + } + + /** + * Outbox endpoint for user's activities. + * GET /users/{username}/outbox + * + * @param username the username + * @return OrderedCollection of activities + */ + @GetMapping( + value = "/users/{username}/outbox", + produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE} + ) + public ResponseEntity outbox(@PathVariable String username) { + log.debug("Outbox request for user: {}", username); + + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String outboxUrl = baseUrl + "/users/" + username + "/outbox"; + + // TODO: Fetch actual activities from database + OrderedCollection collection = OrderedCollection.empty(outboxUrl); + + return ResponseEntity.ok(collection); + } + + /** + * Followers collection endpoint. + * GET /users/{username}/followers + * + * @param username the username + * @return OrderedCollection of followers + */ + @GetMapping( + value = "/users/{username}/followers", + produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE} + ) + public ResponseEntity followers(@PathVariable String username) { + log.debug("Followers request for user: {}", username); + + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String followersUrl = baseUrl + "/users/" + username + "/followers"; + + // TODO: Fetch actual followers from database + OrderedCollection collection = OrderedCollection.empty(followersUrl); + + return ResponseEntity.ok(collection); + } + + /** + * Following collection endpoint. + * GET /users/{username}/following + * + * @param username the username + * @return OrderedCollection of following + */ + @GetMapping( + value = "/users/{username}/following", + produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE} + ) + public ResponseEntity following(@PathVariable String username) { + log.debug("Following request for user: {}", username); + + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String followingUrl = baseUrl + "/users/" + username + "/following"; + + // TODO: Fetch actual following from database + OrderedCollection collection = OrderedCollection.empty(followingUrl); + + return ResponseEntity.ok(collection); + } +} diff --git a/src/main/java/org/operaton/fitpub/controller/WebFingerController.java b/src/main/java/org/operaton/fitpub/controller/WebFingerController.java new file mode 100644 index 0000000..4083325 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/WebFingerController.java @@ -0,0 +1,98 @@ +package org.operaton.fitpub.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.activitypub.WebFingerResponse; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Optional; + +/** + * WebFinger controller for user discovery. + * Implements RFC 7033 WebFinger protocol. + * + * Example: /.well-known/webfinger?resource=acct:username@domain.com + */ +@RestController +@RequiredArgsConstructor +@Slf4j +public class WebFingerController { + + private final UserRepository userRepository; + + @Value("${fitpub.domain}") + private String domain; + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * WebFinger endpoint for user discovery. + * + * @param resource the resource identifier (acct:username@domain) + * @return WebFinger response + */ + @GetMapping(value = "/.well-known/webfinger", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity webfinger(@RequestParam("resource") String resource) { + log.debug("WebFinger request for resource: {}", resource); + + // Parse resource identifier + String username = parseUsername(resource); + if (username == null) { + log.warn("Invalid WebFinger resource format: {}", resource); + return ResponseEntity.badRequest().build(); + } + + // Look up user + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + log.warn("User not found for WebFinger request: {}", username); + return ResponseEntity.notFound().build(); + } + + User user = userOpt.get(); + String actorUrl = user.getActorUri(baseUrl); + + WebFingerResponse response = WebFingerResponse.forUser(username, domain, actorUrl); + return ResponseEntity.ok(response); + } + + /** + * Parses the username from an acct: URI. + * + * @param resource the resource URI (acct:username@domain) + * @return the username or null if invalid + */ + private String parseUsername(String resource) { + if (!resource.startsWith("acct:")) { + return null; + } + + // Remove "acct:" prefix + String acct = resource.substring(5); + + // Split on @ + String[] parts = acct.split("@"); + if (parts.length != 2) { + return null; + } + + String username = parts[0]; + String requestedDomain = parts[1]; + + // Verify domain matches + if (!requestedDomain.equalsIgnoreCase(domain)) { + log.warn("WebFinger request for different domain: {} (ours: {})", requestedDomain, domain); + return null; + } + + return username; + } +} diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java b/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java new file mode 100644 index 0000000..d9bcaf3 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java @@ -0,0 +1,102 @@ +package org.operaton.fitpub.model.activitypub; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * ActivityPub Actor object. + * Represents a user's ActivityPub profile. + * + * Spec: https://www.w3.org/TR/activitypub/#actors + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Actor { + + @JsonProperty("@context") + private Object context; + + private String type; + private String id; + private String preferredUsername; + private String name; + private String summary; + private String inbox; + private String outbox; + private String followers; + private String following; + private PublicKey publicKey; + private Image icon; + private String url; + + /** + * Creates an Actor from a User entity. + */ + public static Actor fromUser(org.operaton.fitpub.model.entity.User user, String baseUrl) { + String actorUri = user.getActorUri(baseUrl); + + return Actor.builder() + .context(List.of( + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + )) + .type("Person") + .id(actorUri) + .preferredUsername(user.getUsername()) + .name(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()) + .summary(user.getBio()) + .inbox(actorUri + "/inbox") + .outbox(actorUri + "/outbox") + .followers(actorUri + "/followers") + .following(actorUri + "/following") + .publicKey(PublicKey.builder() + .id(actorUri + "#main-key") + .owner(actorUri) + .publicKeyPem(user.getPublicKey()) + .build()) + .icon(user.getAvatarUrl() != null ? Image.builder() + .type("Image") + .mediaType("image/jpeg") + .url(user.getAvatarUrl()) + .build() : null) + .url(baseUrl + "/@" + user.getUsername()) + .build(); + } + + /** + * Public key object for HTTP signature verification. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class PublicKey { + private String id; + private String owner; + private String publicKeyPem; + } + + /** + * Image object for avatars. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Image { + private String type; + private String mediaType; + private String url; + } +} diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java b/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java new file mode 100644 index 0000000..f17376e --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java @@ -0,0 +1,60 @@ +package org.operaton.fitpub.model.activitypub; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * ActivityPub OrderedCollection. + * Used for inbox, outbox, followers, and following collections. + * + * Spec: https://www.w3.org/TR/activitystreams-core/#collections + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OrderedCollection { + + @JsonProperty("@context") + private String context; + + private String type; + private String id; + private Integer totalItems; + private String first; + private String last; + private List orderedItems; + + /** + * Creates an empty OrderedCollection. + */ + public static OrderedCollection empty(String id) { + return OrderedCollection.builder() + .context("https://www.w3.org/ns/activitystreams") + .type("OrderedCollection") + .id(id) + .totalItems(0) + .orderedItems(List.of()) + .build(); + } + + /** + * Creates an OrderedCollection with items. + */ + public static OrderedCollection of(String id, List items) { + return OrderedCollection.builder() + .context("https://www.w3.org/ns/activitystreams") + .type("OrderedCollection") + .id(id) + .totalItems(items.size()) + .orderedItems(items) + .build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResponse.java b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResponse.java new file mode 100644 index 0000000..732bb11 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/activitypub/WebFingerResponse.java @@ -0,0 +1,65 @@ +package org.operaton.fitpub.model.activitypub; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * WebFinger response format (RFC 7033). + * Used for user discovery in ActivityPub. + * + * Spec: https://datatracker.ietf.org/doc/html/rfc7033 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WebFingerResponse { + + private String subject; + private List aliases; + private List links; + + /** + * Creates a WebFinger response for a user. + */ + public static WebFingerResponse forUser(String username, String domain, String actorUrl) { + String acctUri = String.format("acct:%s@%s", username, domain); + + return WebFingerResponse.builder() + .subject(acctUri) + .aliases(List.of(actorUrl)) + .links(List.of( + Link.builder() + .rel("self") + .type("application/activity+json") + .href(actorUrl) + .build(), + Link.builder() + .rel("http://webfinger.net/rel/profile-page") + .type("text/html") + .href(actorUrl) + .build() + )) + .build(); + } + + /** + * WebFinger link object. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Link { + private String rel; + private String type; + private String href; + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityUpdateRequest.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityUpdateRequest.java new file mode 100644 index 0000000..6857fbb --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityUpdateRequest.java @@ -0,0 +1,29 @@ +package org.operaton.fitpub.model.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.Activity; + +/** + * Request DTO for updating activity metadata. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActivityUpdateRequest { + + @NotNull(message = "Title is required") + @Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters") + private String title; + + @Size(max = 5000, message = "Description must not exceed 5000 characters") + private String description; + + @NotNull(message = "Visibility is required") + private Activity.Visibility visibility; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java new file mode 100644 index 0000000..f391610 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityUploadRequest.java @@ -0,0 +1,29 @@ +package org.operaton.fitpub.model.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.Activity; + +/** + * Request DTO for uploading a new activity. + * Used with multipart file upload. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActivityUploadRequest { + + @Size(max = 200, message = "Title must not exceed 200 characters") + private String title; + + @Size(max = 5000, message = "Description must not exceed 5000 characters") + private String description; + + @NotNull(message = "Visibility is required") + private Activity.Visibility visibility; +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Activity.java b/src/main/java/org/operaton/fitpub/model/entity/Activity.java index 6e60f63..de8097d 100644 --- a/src/main/java/org/operaton/fitpub/model/entity/Activity.java +++ b/src/main/java/org/operaton/fitpub/model/entity/Activity.java @@ -90,8 +90,8 @@ public class Activity { * Original FIT file for re-processing if needed. * Allows us to re-parse with updated algorithms. */ - @Column(name = "raw_fit_file") @Lob + @Column(name = "raw_fit_file") private byte[] rawFitFile; @OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/org/operaton/fitpub/model/entity/User.java b/src/main/java/org/operaton/fitpub/model/entity/User.java new file mode 100644 index 0000000..846da9d --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/User.java @@ -0,0 +1,94 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * User entity representing a local user account. + * Each user has an ActivityPub Actor profile for federation. + */ +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_user_username", columnList = "username", unique = true), + @Index(name = "idx_user_email", columnList = "email", unique = true) +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(columnDefinition = "TEXT") + private String bio; + + @Column(name = "avatar_url") + private String avatarUrl; + + /** + * RSA public key for ActivityPub HTTP Signatures. + * Used by remote servers to verify signed requests from this user. + */ + @Column(name = "public_key", columnDefinition = "TEXT", nullable = false) + private String publicKey; + + /** + * RSA private key for signing ActivityPub requests. + * Encrypted at rest (handled by application layer). + */ + @Column(name = "private_key", columnDefinition = "TEXT", nullable = false) + private String privateKey; + + @Column(nullable = false) + @Builder.Default + private boolean enabled = true; + + @Column(nullable = false) + @Builder.Default + private boolean locked = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * Gets the ActivityPub actor URI for this user. + * Format: https://domain/users/{username} + */ + public String getActorUri(String baseUrl) { + return String.format("%s/users/%s", baseUrl, username); + } + + /** + * Gets the WebFinger account identifier. + * Format: acct:username@domain + */ + public String getWebFingerAccount(String domain) { + return String.format("acct:%s@%s", username, domain); + } +} diff --git a/src/main/java/org/operaton/fitpub/repository/UserRepository.java b/src/main/java/org/operaton/fitpub/repository/UserRepository.java new file mode 100644 index 0000000..6839d60 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/UserRepository.java @@ -0,0 +1,60 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for User entity. + * Provides methods for user authentication and discovery. + */ +@Repository +public interface UserRepository extends JpaRepository { + + /** + * Finds a user by username. + * Used for login and WebFinger discovery. + * + * @param username the username + * @return optional user + */ + Optional findByUsername(String username); + + /** + * Finds a user by email. + * Used for login and duplicate email checking. + * + * @param email the email address + * @return optional user + */ + Optional findByEmail(String email); + + /** + * Checks if a username already exists. + * Used during registration. + * + * @param username the username to check + * @return true if username exists + */ + boolean existsByUsername(String username); + + /** + * Checks if an email already exists. + * Used during registration. + * + * @param email the email to check + * @return true if email exists + */ + boolean existsByEmail(String email); + + /** + * Finds all enabled users. + * Used for administrative purposes. + * + * @return list of enabled users + */ + Optional findByUsernameAndEnabledTrue(String username); +} diff --git a/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java b/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java new file mode 100644 index 0000000..1e4e194 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/security/HttpSignatureValidator.java @@ -0,0 +1,199 @@ +package org.operaton.fitpub.security; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Validates HTTP Signatures for ActivityPub federation. + * Implements the HTTP Signatures specification used by ActivityPub. + * + * Reference: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class HttpSignatureValidator { + + private static final Pattern SIGNATURE_PATTERN = Pattern.compile( + "keyId=\"([^\"]+)\",algorithm=\"([^\"]+)\",headers=\"([^\"]+)\",signature=\"([^\"]+)\"" + ); + + /** + * Validates an HTTP signature. + * + * @param signatureHeader the Signature header value + * @param headers the HTTP headers map + * @param publicKeyPem the actor's public key in PEM format + * @return true if signature is valid + */ + public boolean validate(String signatureHeader, Map headers, String publicKeyPem) { + try { + // Parse signature header + Matcher matcher = SIGNATURE_PATTERN.matcher(signatureHeader); + if (!matcher.find()) { + log.warn("Invalid signature header format"); + return false; + } + + String keyId = matcher.group(1); + String algorithm = matcher.group(2); + String headersString = matcher.group(3); + String signatureBase64 = matcher.group(4); + + // Build signing string from specified headers + String signingString = buildSigningString(headersString, headers); + + // Decode signature + byte[] signature = Base64.getDecoder().decode(signatureBase64); + + // Parse public key + PublicKey publicKey = parsePublicKey(publicKeyPem); + + // Verify signature + return verifySignature(signingString, signature, publicKey, algorithm); + + } catch (Exception e) { + log.error("Error validating HTTP signature", e); + return false; + } + } + + /** + * Builds the signing string from the specified headers. + * + * @param headersString space-separated list of header names + * @param headers the actual header values + * @return the signing string + */ + private String buildSigningString(String headersString, Map headers) { + String[] headerNames = headersString.split(" "); + StringBuilder signingString = new StringBuilder(); + + for (int i = 0; i < headerNames.length; i++) { + String headerName = headerNames[i].toLowerCase(); + String headerValue = headers.get(headerName); + + if (headerValue == null) { + log.warn("Header {} specified in signature but not found in request", headerName); + headerValue = ""; + } + + // Special handling for (request-target) pseudo-header + if (headerName.equals("(request-target)")) { + signingString.append(headerName).append(": ").append(headerValue); + } else { + signingString.append(headerName).append(": ").append(headerValue); + } + + if (i < headerNames.length - 1) { + signingString.append("\n"); + } + } + + return signingString.toString(); + } + + /** + * Parses a PEM-encoded public key. + * + * @param publicKeyPem the public key in PEM format + * @return the PublicKey object + */ + private PublicKey parsePublicKey(String publicKeyPem) throws Exception { + // Remove PEM headers and whitespace + String publicKeyContent = publicKeyPem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + // Decode Base64 + byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent); + + // Create PublicKey + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(spec); + } + + /** + * Verifies the signature using the public key. + * + * @param signingString the string that was signed + * @param signature the signature bytes + * @param publicKey the public key + * @param algorithm the signature algorithm + * @return true if signature is valid + */ + private boolean verifySignature(String signingString, byte[] signature, PublicKey publicKey, String algorithm) + throws Exception { + + // Map ActivityPub algorithm names to Java algorithm names + String javaAlgorithm = mapAlgorithm(algorithm); + + Signature sig = Signature.getInstance(javaAlgorithm); + sig.initVerify(publicKey); + sig.update(signingString.getBytes(StandardCharsets.UTF_8)); + + return sig.verify(signature); + } + + /** + * Maps ActivityPub algorithm names to Java Signature algorithm names. + * + * @param activityPubAlgorithm the ActivityPub algorithm name + * @return the Java algorithm name + */ + private String mapAlgorithm(String activityPubAlgorithm) { + switch (activityPubAlgorithm.toLowerCase()) { + case "rsa-sha256": + return "SHA256withRSA"; + case "rsa-sha512": + return "SHA512withRSA"; + default: + log.warn("Unknown signature algorithm: {}, defaulting to SHA256withRSA", activityPubAlgorithm); + return "SHA256withRSA"; + } + } + + /** + * Signs a message using a private key. + * Used for outbound federation requests. + * + * @param signingString the string to sign + * @param privateKeyPem the private key in PEM format + * @return Base64-encoded signature + */ + public String sign(String signingString, String privateKeyPem) throws Exception { + // Parse private key + String privateKeyContent = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + java.security.spec.PKCS8EncodedKeySpec spec = new java.security.spec.PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + java.security.PrivateKey privateKey = keyFactory.generatePrivate(spec); + + // Sign + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initSign(privateKey); + sig.update(signingString.getBytes(StandardCharsets.UTF_8)); + byte[] signature = sig.sign(); + + return Base64.getEncoder().encodeToString(signature); + } +} diff --git a/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java b/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..318a1c1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/security/JwtAuthenticationFilter.java @@ -0,0 +1,73 @@ +package org.operaton.fitpub.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT authentication filter that validates JWT tokens on each request. + * Extracts the token from the Authorization header and authenticates the user. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + try { + String jwt = getJwtFromRequest(request); + + if (jwt != null && tokenProvider.validateToken(jwt)) { + String username = tokenProvider.getUsername(jwt); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Set authentication for user: {}", username); + } + } catch (Exception e) { + log.error("Could not set user authentication in security context", e); + } + + filterChain.doFilter(request, response); + } + + /** + * Extracts the JWT token from the Authorization header. + * + * @param request the HTTP request + * @return the JWT token or null if not found + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + return tokenProvider.resolveToken(bearerToken); + } +} diff --git a/src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java b/src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java new file mode 100644 index 0000000..cf00bb1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java @@ -0,0 +1,113 @@ +package org.operaton.fitpub.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * JWT token provider for creating and validating authentication tokens. + * Tokens are used for session management in the REST API. + */ +@Component +@Slf4j +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long validityInMilliseconds; + + public JwtTokenProvider( + @Value("${fitpub.security.jwt.secret}") String secret, + @Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds + ) { + // Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes) + if (secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("JWT secret must be at least 32 characters long"); + } + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.validityInMilliseconds = validityInMilliseconds; + } + + /** + * Creates a JWT token for an authenticated user. + * + * @param authentication the authentication object + * @return JWT token string + */ + public String createToken(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return createToken(userDetails.getUsername()); + } + + /** + * Creates a JWT token for a username. + * + * @param username the username + * @return JWT token string + */ + public String createToken(String username) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setSubject(username) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * Extracts the username from a JWT token. + * + * @param token the JWT token + * @return username + */ + public String getUsername(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + /** + * Validates a JWT token. + * + * @param token the JWT token + * @return true if token is valid + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + /** + * Extracts the token from the Authorization header. + * + * @param bearerToken the Authorization header value + * @return the token or null if not found + */ + public String resolveToken(String bearerToken) { + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/org/operaton/fitpub/security/UserDetailsServiceImpl.java b/src/main/java/org/operaton/fitpub/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..190ae66 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/security/UserDetailsServiceImpl.java @@ -0,0 +1,57 @@ +package org.operaton.fitpub.security; + +import lombok.RequiredArgsConstructor; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Collections; + +/** + * Custom UserDetailsService implementation for Spring Security. + * Loads user-specific data from the database for authentication. + */ +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPasswordHash(), + user.isEnabled(), + true, // accountNonExpired + true, // credentialsNonExpired + !user.isLocked(), // accountNonLocked + getAuthorities(user) + ); + } + + /** + * Gets the authorities for a user. + * Currently, all users have the same ROLE_USER authority. + * This can be extended to support roles and permissions in the future. + * + * @param user the user + * @return collection of granted authorities + */ + private Collection getAuthorities(User user) { + // For MVP, all users have the same role + // Future: Add roles/permissions to User entity + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9793403 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,74 @@ +spring: + application: + name: fitpub + + # Datasource configuration is handled by Testcontainers in dev mode + # For production, set these via environment variables: + # - SPRING_DATASOURCE_URL + # - SPRING_DATASOURCE_USERNAME + # - SPRING_DATASOURCE_PASSWORD + datasource: + url: ${SPRING_DATASOURCE_URL:} + username: ${SPRING_DATASOURCE_USERNAME:} + password: ${SPRING_DATASOURCE_PASSWORD:} + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + use_sql_comments: true + show-sql: false + + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + enabled: true + + jackson: + serialization: + write-dates-as-timestamps: false + time-zone: UTC + +# FitPub specific configuration +fitpub: + # Domain and URL configuration + domain: ${FITPUB_DOMAIN:localhost:8080} + base-url: ${FITPUB_BASE_URL:http://localhost:8080} + + # ActivityPub federation settings + activitypub: + enabled: true + max-federation-retries: 3 + request-timeout-seconds: 30 + + # Security settings + security: + jwt: + secret: ${JWT_SECRET:change-this-secret-key-in-production-must-be-at-least-32-characters-long} + expiration: 86400000 # 24 hours in milliseconds + + # Storage settings + storage: + fit-files: + enabled: true + retention-days: 365 + +# Logging configuration +logging: + level: + root: INFO + org.operaton.fitpub: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.springframework.security: DEBUG + +# Server configuration +server: + port: ${PORT:8080} + error: + include-message: always + include-binding-errors: always