mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
support for user deleting builds, fixed a lot of auth with the vault
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -19,4 +19,6 @@ public interface BuildService {
|
||||
BuildDto createMyBuild(UpdateBuildRequest req);
|
||||
|
||||
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
||||
|
||||
void deleteMyBuild(UUID uuid);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -40,4 +40,6 @@ public class BuildDto {
|
||||
|
||||
public List<BuildItemDto> getItems() { return items; }
|
||||
public void setItems(List<BuildItemDto> items) { this.items = items; }
|
||||
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
Reference in New Issue
Block a user