feat(back-end and front-end): back-office
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 38s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-27 12:44:06 +01:00
parent 1598f35c08
commit 3f938db257
32 changed files with 1293 additions and 30 deletions

View File

@@ -0,0 +1,45 @@
package com.printcalculator.config;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/admin/auth/login").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().permitAll()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
}))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,55 @@
package com.printcalculator.controller;
import com.printcalculator.dto.AdminLoginRequest;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminSessionService adminSessionService;
public AdminAuthController(AdminSessionService adminSessionService) {
this.adminSessionService = adminSessionService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@Valid @RequestBody AdminLoginRequest request,
HttpServletResponse response
) {
if (!adminSessionService.isPasswordValid(request.getPassword())) {
return ResponseEntity.status(401).body(Map.of("authenticated", false));
}
String token = adminSessionService.createSessionToken();
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
return ResponseEntity.ok(Map.of(
"authenticated", true,
"expiresInMinutes", adminSessionService.getSessionTtlMinutes()
));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString());
return ResponseEntity.ok(Map.of("authenticated", false));
}
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me() {
return ResponseEntity.ok(Map.of("authenticated", true));
}
}

View File

@@ -0,0 +1,156 @@
package com.printcalculator.controller;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.PaymentService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin/orders")
public class AdminOrderController {
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
public AdminOrderController(
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> confirmPayment(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.NotBlank;
public class AdminLoginRequest {
@NotBlank
private String password;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> {
}
List<Order> findAllByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,71 @@
package com.printcalculator.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component
public class AdminSessionAuthenticationFilter extends OncePerRequestFilter {
private final AdminSessionService adminSessionService;
public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) {
this.adminSessionService = adminSessionService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (!path.startsWith("/api/admin/")) {
return true;
}
return "/api/admin/auth/login".equals(path);
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional<String> token = adminSessionService.extractTokenFromCookies(request);
Optional<AdminSessionService.AdminSessionPayload> payload = token.flatMap(adminSessionService::validateSessionToken);
if (payload.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
return;
}
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
"admin",
null,
Collections.emptyList()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String resolvePath(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
return path.substring(contextPath.length());
}
return path;
}
}

View File

@@ -0,0 +1,202 @@
package com.printcalculator.security;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
@Service
public class AdminSessionService {
public static final String COOKIE_NAME = "admin_session";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final ObjectMapper objectMapper;
private final String adminPassword;
private final byte[] sessionSecret;
private final long sessionTtlMinutes;
public AdminSessionService(
ObjectMapper objectMapper,
@Value("${admin.password}") String adminPassword,
@Value("${admin.session.secret}") String sessionSecret,
@Value("${admin.session.ttl-minutes}") long sessionTtlMinutes
) {
this.objectMapper = objectMapper;
this.adminPassword = adminPassword;
this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8);
this.sessionTtlMinutes = sessionTtlMinutes;
validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes);
}
public boolean isPasswordValid(String candidatePassword) {
if (candidatePassword == null) {
return false;
}
return MessageDigest.isEqual(
adminPassword.getBytes(StandardCharsets.UTF_8),
candidatePassword.getBytes(StandardCharsets.UTF_8)
);
}
public String createSessionToken() {
Instant now = Instant.now();
AdminSessionPayload payload = new AdminSessionPayload(
now.getEpochSecond(),
now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(),
UUID.randomUUID().toString()
);
try {
String payloadJson = objectMapper.writeValueAsString(payload);
String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlEncode(sign(encodedPayload));
return encodedPayload + "." + signature;
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot create admin session token", e);
}
}
public Optional<AdminSessionPayload> validateSessionToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 2) {
return Optional.empty();
}
String encodedPayload = parts[0];
String encodedSignature = parts[1];
byte[] providedSignature;
try {
providedSignature = base64UrlDecode(encodedSignature);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
byte[] expectedSignature = sign(encodedPayload);
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
return Optional.empty();
}
try {
byte[] decodedPayload = base64UrlDecode(encodedPayload);
AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class);
if (payload.exp <= Instant.now().getEpochSecond()) {
return Optional.empty();
}
return Optional.of(payload);
} catch (IllegalArgumentException | IOException e) {
return Optional.empty();
}
}
public Optional<String> extractTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue());
}
}
return Optional.empty();
}
public ResponseCookie buildLoginCookie(String token) {
return ResponseCookie.from(COOKIE_NAME, token)
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(Duration.ofMinutes(sessionTtlMinutes))
.build();
}
public ResponseCookie buildLogoutCookie() {
return ResponseCookie.from(COOKIE_NAME, "")
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(Duration.ZERO)
.build();
}
public long getSessionTtlMinutes() {
return sessionTtlMinutes;
}
private byte[] sign(String encodedPayload) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM));
return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Cannot sign admin session token", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
private byte[] base64UrlDecode(String data) {
return Base64.getUrlDecoder().decode(data);
}
private void validateConfiguration(String password, String secret, long ttlMinutes) {
if (password == null || password.isBlank()) {
throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty");
}
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty");
}
if (secret.length() < 32) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long");
}
if (ttlMinutes <= 0) {
throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0");
}
}
public static class AdminSessionPayload {
@JsonProperty("iat")
public long iat;
@JsonProperty("exp")
public long exp;
@JsonProperty("nonce")
public String nonce;
public AdminSessionPayload() {
}
public AdminSessionPayload(long iat, long exp, String nonce) {
this.iat = iat;
this.exp = exp;
this.nonce = nonce;
}
}
}

View File

@@ -1,2 +1,7 @@
app.mail.enabled=false
app.mail.admin.enabled=false
# Admin back-office local test credentials
admin.password=local-admin-password
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
admin.session.ttl-minutes=480

View File

@@ -41,3 +41,8 @@ app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
# Admin back-office authentication
admin.password=${ADMIN_PASSWORD}
admin.session.secret=${ADMIN_SESSION_SECRET}
admin.session.ttl-minutes=${ADMIN_SESSION_TTL_MINUTES:480}