diff --git a/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java b/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java index 4e5bf76..70922aa 100644 --- a/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java +++ b/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java @@ -3,6 +3,7 @@ package group.goforward.battlbuilder.configuration; import group.goforward.battlbuilder.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.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,7 +13,12 @@ import org.springframework.security.config.http.SessionCreationPolicy; 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.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -28,19 +34,42 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(c -> c.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // public .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/api/products/gunbuilder/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll() + + // protected + .requestMatchers("/api/v1/builds/me/**").authenticated() + + // everything else (adjust later as you lock down) .anyRequest().permitAll() ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + // run JWT before AnonymousAuth sets principal="anonymousUser" + .addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class); return http.build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration cfg = new CorsConfiguration(); + cfg.setAllowedOrigins(List.of("http://localhost:3000")); + cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + cfg.setAllowedHeaders(List.of("Authorization", "Content-Type")); + cfg.setExposedHeaders(List.of("Authorization")); + cfg.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", cfg); + return source; + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java b/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java index 1f3c9fe..4d3e04b 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/BuildV1Controller.java @@ -7,6 +7,7 @@ import group.goforward.battlbuilder.web.dto.BuildSummaryDto; import group.goforward.battlbuilder.web.dto.UpdateBuildRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; import java.util.List; import java.util.UUID; @@ -73,4 +74,15 @@ public class BuildV1Controller { ) { return ResponseEntity.ok(buildService.updateMyBuild(uuid, req)); } + + + /** + * Delete a build (authenticated user; must own build). + * DELETE /api/v1/builds/me/{uuid} + */ + @DeleteMapping("/me/{uuid}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteMyBuild(@PathVariable("uuid") UUID uuid) { + buildService.deleteMyBuild(uuid); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java b/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java index ee84d47..0af62e6 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/BuildRepository.java @@ -15,5 +15,7 @@ public interface BuildRepository extends JpaRepository { // Temporary vault behavior until Build.user exists: Page findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable); + Page findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(Integer userId, Pageable pageable); + Optional findByUuidAndDeletedAtIsNull(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java index 487f9d7..6a2baf9 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; @@ -41,8 +42,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { return; } - // Already authenticated? don’t redo work - if (SecurityContextHolder.getContext().getAuthentication() != null) { + // ✅ If already authenticated with a REAL user, skip. + // ✅ If it's anonymous, we should continue and replace it. + var existing = SecurityContextHolder.getContext().getAuthentication(); + if (existing != null + && existing.isAuthenticated() + && !(existing instanceof AnonymousAuthenticationToken)) { filterChain.doFilter(request, response); return; } @@ -61,19 +66,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } User user = userRepository.findByUuid(userUuid).orElse(null); - if (user == null || !Boolean.TRUE.equals(user.getIsActive())) { filterChain.doFilter(request, response); return; } - // Keep authorities from your details class… CustomUserDetails userDetails = new CustomUserDetails(user); - // …but set principal to UUID string so controllers can reliably resolve "me" UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - user.getUuid().toString(), + user.getUuid().toString(), // principal = UUID string null, userDetails.getAuthorities() ); diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtService.java b/src/main/java/group/goforward/battlbuilder/security/JwtService.java index 3360e19..cf3fd42 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtService.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtService.java @@ -9,6 +9,7 @@ import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -27,7 +28,8 @@ public class JwtService { @Value("${security.jwt.secret}") String secret, @Value("${security.jwt.access-token-days:30}") long accessTokenDays ) { - this.key = Keys.hmacShaKeyFor(secret.getBytes()); + // Use a stable charset (avoid platform default weirdness) + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.accessTokenDays = accessTokenDays; } @@ -42,7 +44,8 @@ public class JwtService { return Jwts.builder() .setClaims(claims) - .setSubject(user.getUuid().toString()) // UUID subject + // ✅ Canonical: UUID goes in `sub` + .setSubject(user.getUuid().toString()) .setIssuedAt(new Date()) .setExpiration(Date.from(Instant.now().plus(accessTokenDays, ChronoUnit.DAYS))) .signWith(key, SignatureAlgorithm.HS256) @@ -51,8 +54,21 @@ public class JwtService { /** Used by JwtAuthenticationFilter */ public UUID extractUserUuid(String token) { - Claims claims = parseClaims(token); - return UUID.fromString(claims.getSubject()); + try { + Claims claims = parseClaims(token); + String sub = claims.getSubject(); + + if (sub == null || sub.isBlank()) return null; + + // ✅ Defensive: old tokens may have sub=email — don't 500 the API + try { + return UUID.fromString(sub); + } catch (IllegalArgumentException ignored) { + return null; + } + } catch (JwtException | IllegalArgumentException ex) { + return null; + } } public boolean isTokenValid(String token) { diff --git a/src/main/java/group/goforward/battlbuilder/services/BuildService.java b/src/main/java/group/goforward/battlbuilder/services/BuildService.java index 1adcf3d..d93954a 100644 --- a/src/main/java/group/goforward/battlbuilder/services/BuildService.java +++ b/src/main/java/group/goforward/battlbuilder/services/BuildService.java @@ -19,4 +19,6 @@ public interface BuildService { BuildDto createMyBuild(UpdateBuildRequest req); BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req); + + void deleteMyBuild(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/CurrentUserService.java b/src/main/java/group/goforward/battlbuilder/services/CurrentUserService.java new file mode 100644 index 0000000..0e0c926 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/services/CurrentUserService.java @@ -0,0 +1,52 @@ +package group.goforward.battlbuilder.security; + +import group.goforward.battlbuilder.model.User; +import group.goforward.battlbuilder.repos.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Service +public class CurrentUserService { + + private final UserRepository userRepository; + + public CurrentUserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** Returns the authenticated User (401 if missing/invalid). */ + public User requireUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // No auth, or anonymous auth => 401 + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + + // In your setup, JwtAuthenticationFilter sets auth name to UUID string + String principal = auth.getName(); + if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + + final UUID userUuid; + try { + userUuid = UUID.fromString(principal); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e); + } + + return userRepository.findByUuid(userUuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); + } + + public Integer requireUserId() { + return requireUser().getId(); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java index 63bf2bc..8109756 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/BuildServiceImpl.java @@ -8,6 +8,7 @@ import group.goforward.battlbuilder.repos.BuildItemRepository; import group.goforward.battlbuilder.repos.BuildProfileRepository; import group.goforward.battlbuilder.repos.BuildRepository; import group.goforward.battlbuilder.repos.ProductOfferRepository; +import group.goforward.battlbuilder.security.CurrentUserService; import group.goforward.battlbuilder.services.BuildService; import group.goforward.battlbuilder.web.dto.BuildDto; import group.goforward.battlbuilder.web.dto.BuildFeedCardDto; @@ -18,6 +19,15 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + import java.math.BigDecimal; import java.math.RoundingMode; import java.util.*; @@ -30,17 +40,20 @@ public class BuildServiceImpl implements BuildService { private final BuildProfileRepository buildProfileRepository; private final BuildItemRepository buildItemRepository; private final ProductOfferRepository productOfferRepository; + private final CurrentUserService currentUserService; public BuildServiceImpl( BuildRepository buildRepository, BuildProfileRepository buildProfileRepository, BuildItemRepository buildItemRepository, - ProductOfferRepository productOfferRepository + ProductOfferRepository productOfferRepository, + CurrentUserService currentUserService ) { this.buildRepository = buildRepository; this.buildProfileRepository = buildProfileRepository; this.buildItemRepository = buildItemRepository; this.productOfferRepository = productOfferRepository; + this.currentUserService = currentUserService; } // --------------------------- @@ -57,7 +70,10 @@ public class BuildServiceImpl implements BuildService { if (builds.isEmpty()) return List.of(); - List buildIds = builds.stream().map(Build::getId).toList(); + List buildIds = builds.stream() + .map(Build::getId) + .filter(Objects::nonNull) + .toList(); Map profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds) .stream() @@ -98,10 +114,10 @@ public class BuildServiceImpl implements BuildService { @Override public List listMyBuilds(int limit) { int safeLimit = clamp(limit, 1, 200); + Integer userId = currentUserService.requireUserId(); - // MVP: ownership not implemented yet -> return all non-deleted builds List builds = buildRepository - .findByDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit)) + .findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId, PageRequest.of(0, safeLimit)) .getContent(); if (builds.isEmpty()) return List.of(); @@ -118,9 +134,16 @@ public class BuildServiceImpl implements BuildService { public BuildDto getMyBuild(UUID uuid) { if (uuid == null) throw new IllegalArgumentException("uuid is required"); + Integer userId = currentUserService.requireUserId(); + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) .orElseThrow(() -> new IllegalArgumentException("Build not found")); + // prevent leaking other users' builds + if (!Objects.equals(build.getUserId(), userId)) { + throw new IllegalArgumentException("Build not found"); + } + List items = buildItemRepository.findByBuild_Id(build.getId()); return toBuildDto(build, items); } @@ -135,11 +158,14 @@ public class BuildServiceImpl implements BuildService { public BuildDto createMyBuild(UpdateBuildRequest req) { if (req == null) throw new IllegalArgumentException("request body is required"); + Integer userId = currentUserService.requireUserId(); + String title = (req.getTitle() == null || req.getTitle().isBlank()) ? "Untitled Build" : req.getTitle().trim(); Build build = new Build(); + build.setUserId(userId); // ✅ IMPORTANT: satisfies NOT NULL constraint build.setTitle(title); build.setDescription(req.getDescription()); build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE); @@ -168,9 +194,15 @@ public class BuildServiceImpl implements BuildService { if (uuid == null) throw new IllegalArgumentException("uuid is required"); if (req == null) throw new IllegalArgumentException("request body is required"); + Integer userId = currentUserService.requireUserId(); + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) .orElseThrow(() -> new IllegalArgumentException("Build not found")); + if (!Objects.equals(build.getUserId(), userId)) { + throw new IllegalArgumentException("Build not found"); + } + if (req.getTitle() != null) build.setTitle(req.getTitle().trim()); if (req.getDescription() != null) build.setDescription(req.getDescription()); if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic()); @@ -187,12 +219,37 @@ public class BuildServiceImpl implements BuildService { return toBuildDto(saved, items); } + // --------------------------- + // Delete My build (Vault edit Delete) + // DELETE /api/v1/builds/me/{uuid} + // --------------------------- + @Override + @Transactional + public void deleteMyBuild(UUID uuid) { + Integer userId = currentUserService.requireUserId(); + + Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found")); + + // Ownership check + Integer currentUserId = currentUserService.requireUserId(); + + if (!currentUserId.equals(build.getUserId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your build"); + } + + build.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + build.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); // optional + buildRepository.save(build); + } + // --------------------------- // BuildItem helper // --------------------------- private List buildItemsFromRequest(Build build, List incoming) { List out = new ArrayList<>(); + if (incoming == null || incoming.isEmpty()) return out; for (UpdateBuildRequest.Item it : incoming) { if (it == null) continue; @@ -202,7 +259,7 @@ public class BuildServiceImpl implements BuildService { BuildItem bi = new BuildItem(); bi.setBuild(build); - // Product proxy by ID only + // Product proxy by ID only (no DB fetch) var product = new group.goforward.battlbuilder.model.Product(); product.setId(it.getProductId()); bi.setProduct(product); @@ -218,7 +275,7 @@ public class BuildServiceImpl implements BuildService { } // --------------------------- - // DTO mapping (Build -> BuildDto) + // DTO mapping // --------------------------- private BuildDto toBuildDto(Build build, List items) { @@ -243,14 +300,13 @@ public class BuildServiceImpl implements BuildService { it.setPosition(bi.getPosition()); it.setQuantity(bi.getQuantity()); - // BuildItemDto.productId is String it.setProductId( (bi.getProduct() != null && bi.getProduct().getId() != null) ? String.valueOf(bi.getProduct().getId()) : null ); - // Optional / safe defaults for now + // optional for now it.setProductName(null); it.setProductBrand(null); it.setProductImageUrl(null); diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java index e80e81c..fdb9cf7 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildDto.java @@ -40,4 +40,6 @@ public class BuildDto { public List getItems() { return items; } public void setItems(List items) { this.items = items; } + + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java index 59716f6..931a0c9 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemDto.java @@ -1,5 +1,6 @@ package group.goforward.battlbuilder.web.dto; +import java.math.BigDecimal; import java.util.UUID; public class BuildItemDto { @@ -41,4 +42,9 @@ public class BuildItemDto { public String getProductImageUrl() { return productImageUrl; } public void setProductImageUrl(String productImageUrl) { this.productImageUrl = productImageUrl; } -} \ No newline at end of file + + private BigDecimal bestPrice; + + public BigDecimal getBestPrice() { return bestPrice; } + public void setBestPrice(BigDecimal bestPrice) { this.bestPrice = bestPrice; } +} diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemSummaryDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemSummaryDto.java new file mode 100644 index 0000000..4ec4e36 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/BuildItemSummaryDto.java @@ -0,0 +1,10 @@ +package group.goforward.battlbuilder.web.dto; + +import java.math.BigDecimal; + +public record BuildItemSummaryDto( + String slot, + Integer productId, + String productName, + BigDecimal bestPrice +) {} \ No newline at end of file