feat(back-end and front-end): back-office
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = AdminAuthController.class)
|
||||
@Import({SecurityConfig.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class})
|
||||
@TestPropertySource(properties = {
|
||||
"admin.password=test-admin-password",
|
||||
"admin.session.secret=0123456789abcdef0123456789abcdef",
|
||||
"admin.session.ttl-minutes=60"
|
||||
})
|
||||
class AdminAuthSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void loginOk_ShouldReturnCookie() throws Exception {
|
||||
MvcResult result = mockMvc.perform(post("/api/admin/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.authenticated").value(true))
|
||||
.andReturn();
|
||||
|
||||
String setCookie = result.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||
assertNotNull(setCookie);
|
||||
assertTrue(setCookie.contains("admin_session="));
|
||||
assertTrue(setCookie.contains("HttpOnly"));
|
||||
assertTrue(setCookie.contains("Secure"));
|
||||
assertTrue(setCookie.contains("SameSite=Lax"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginKo_ShouldReturnUnauthorized() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.authenticated").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/auth/me"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminAccessWithValidCookie_ShouldReturn200() throws Exception {
|
||||
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||
assertNotNull(setCookie);
|
||||
|
||||
Cookie adminCookie = toCookie(setCookie);
|
||||
mockMvc.perform(get("/api/admin/auth/me").cookie(adminCookie))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.authenticated").value(true));
|
||||
}
|
||||
|
||||
private Cookie toCookie(String setCookieHeader) {
|
||||
String[] parts = setCookieHeader.split(";", 2);
|
||||
String[] keyValue = parts[0].split("=", 2);
|
||||
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user