support for user deleting builds, fixed a lot of auth with the vault

This commit is contained in:
2025-12-21 07:20:05 -05:00
parent 74b71ea10a
commit 5bfbc4707a
11 changed files with 210 additions and 21 deletions

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -15,5 +15,7 @@ public interface BuildRepository extends JpaRepository<Build, Integer> {
// Temporary vault behavior until Build.user exists:
Page<Build> findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable);
Page<Build> findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(Integer userId, Pageable pageable);
Optional<Build> findByUuidAndDeletedAtIsNull(UUID uuid);
}

View File

@@ -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? dont 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()
);

View File

@@ -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) {

View File

@@ -19,4 +19,6 @@ public interface BuildService {
BuildDto createMyBuild(UpdateBuildRequest req);
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
void deleteMyBuild(UUID uuid);
}

View File

@@ -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();
}
}

View File

@@ -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<Integer> buildIds = builds.stream().map(Build::getId).toList();
List<Integer> buildIds = builds.stream()
.map(Build::getId)
.filter(Objects::nonNull)
.toList();
Map<Integer, BuildProfile> profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds)
.stream()
@@ -98,10 +114,10 @@ public class BuildServiceImpl implements BuildService {
@Override
public List<BuildSummaryDto> listMyBuilds(int limit) {
int safeLimit = clamp(limit, 1, 200);
Integer userId = currentUserService.requireUserId();
// MVP: ownership not implemented yet -> return all non-deleted builds
List<Build> 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<BuildItem> 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<BuildItem> buildItemsFromRequest(Build build, List<UpdateBuildRequest.Item> incoming) {
List<BuildItem> 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<BuildItem> 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);

View File

@@ -40,4 +40,6 @@ public class BuildDto {
public List<BuildItemDto> getItems() { return items; }
public void setItems(List<BuildItemDto> items) { this.items = items; }
}

View File

@@ -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; }
}
private BigDecimal bestPrice;
public BigDecimal getBestPrice() { return bestPrice; }
public void setBestPrice(BigDecimal bestPrice) { this.bestPrice = bestPrice; }
}

View File

@@ -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
) {}