From 203d9b0b473082b059fa935c3bd183a60dff3c50 Mon Sep 17 00:00:00 2001 From: Jaume Garriga Maestre Date: Mon, 13 Jul 2026 22:07:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(saas):=20recuperaci=C3=B3n=20de=20contrase?= =?UTF-8?q?=C3=B1a,=20freno=20de=20fuerza=20bruta=20y=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bloque A de la fase SaaS: - Recuperación por email: código de un solo uso (solo su SHA-256 en BD), caducidad 30 min, emitir uno nuevo invalida los anteriores, respuesta idéntica exista o no la cuenta (anti-enumeración) y email asíncrono; restablecer cierra todas las sesiones de la familia. MailService cae a modo log sin SMTP configurado. - LoginAttemptService: 5 fallos -> bloqueo con backoff exponencial (429 + Retry-After) en login, PIN del panel y solicitudes de reset, comprobado ANTES de evaluar credenciales. - Registro exige aceptar la política de privacidad (privacy_accepted_at). - Borrado definitivo de cuenta (RGPD) con confirmación por contraseña. - CORS por configuración; seeder demo fail-safe (matchIfMissing=false, activado explícito en el compose local); application-prod.yml. - Liquibase 005 con rollback. 44 tests verdes incl. migración en Postgres. --- backend/build.gradle | 3 + .../recordalexia/bootstrap/DataSeeder.java | 5 +- .../config/ConcurrencyConfig.java | 16 ++ .../asepeyo/recordalexia/domain/Family.java | 12 ++ .../recordalexia/domain/RecoveryCode.java | 90 +++++++++++ .../exception/GlobalExceptionHandler.java | 9 ++ .../exception/TooManyAttemptsException.java | 20 +++ .../repository/FamilySessionRepository.java | 3 + .../repository/RecoveryCodeRepository.java | 13 ++ .../security/AccountRecoveryService.java | 112 +++++++++++++ .../recordalexia/security/AuthService.java | 6 +- .../security/LoginAttemptService.java | 100 ++++++++++++ .../recordalexia/security/SecurityConfig.java | 21 ++- .../recordalexia/service/MailService.java | 74 +++++++++ .../recordalexia/web/AccountController.java | 27 +++- .../recordalexia/web/AuthController.java | 73 ++++++++- .../recordalexia/web/PanelController.java | 14 +- .../recordalexia/web/dto/AuthDtos.java | 15 +- .../src/main/resources/application-prod.yml | 19 +++ backend/src/main/resources/application.yml | 13 ++ .../changes/005-account-recovery.yaml | 41 +++++ .../security/LoginAttemptServiceTest.java | 108 +++++++++++++ .../recordalexia/web/AccountDeletionIT.java | 126 +++++++++++++++ .../recordalexia/web/AccountRecoveryIT.java | 148 ++++++++++++++++++ .../es/asepeyo/recordalexia/web/AuthIT.java | 20 ++- .../asepeyo/recordalexia/web/RateLimitIT.java | 112 +++++++++++++ docker-compose.yml | 3 + 27 files changed, 1181 insertions(+), 22 deletions(-) create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/config/ConcurrencyConfig.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/domain/RecoveryCode.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/exception/TooManyAttemptsException.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/repository/RecoveryCodeRepository.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/AccountRecoveryService.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/LoginAttemptService.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/service/MailService.java create mode 100644 backend/src/main/resources/application-prod.yml create mode 100644 backend/src/main/resources/db/changelog/changes/005-account-recovery.yaml create mode 100644 backend/src/test/java/es/asepeyo/recordalexia/security/LoginAttemptServiceTest.java create mode 100644 backend/src/test/java/es/asepeyo/recordalexia/web/AccountDeletionIT.java create mode 100644 backend/src/test/java/es/asepeyo/recordalexia/web/AccountRecoveryIT.java create mode 100644 backend/src/test/java/es/asepeyo/recordalexia/web/RateLimitIT.java diff --git a/backend/build.gradle b/backend/build.gradle index e04e89f..2284d94 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -23,6 +23,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + // Recuperación de contraseña por email; sin spring.mail.host configurado la app + // funciona igual (MailService cae a modo log para desarrollo y tests). + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.liquibase:liquibase-core' runtimeOnly 'org.postgresql:postgresql' // H2 en test para los tests rápidos de servicio/web. La verificación de las diff --git a/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java b/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java index 48fa9a5..df1ae0e 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java @@ -37,8 +37,11 @@ import org.springframework.transaction.annotation.Transactional; * * Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234. */ +// FAIL-SAFE: sin la propiedad, NO se siembra (una instancia pública desplegada sin +// configurar jamás debe nacer con la familia demo y su PIN público). El compose +// local y los tests que la necesitan la activan explícitamente. @Component -@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = false) public class DataSeeder implements ApplicationRunner { private static final String DEMO_EMAIL = "demo@recordalexia.local"; diff --git a/backend/src/main/java/es/asepeyo/recordalexia/config/ConcurrencyConfig.java b/backend/src/main/java/es/asepeyo/recordalexia/config/ConcurrencyConfig.java new file mode 100644 index 0000000..f7f1ce2 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/config/ConcurrencyConfig.java @@ -0,0 +1,16 @@ +package es.asepeyo.recordalexia.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * @Async: el email de recuperación se envía fuera del hilo de la petición para que + * la respuesta tarde lo mismo exista o no la cuenta (anti-enumeración por timing). + * @Scheduled: purga periódica del rate-limiter. + */ +@Configuration +@EnableAsync +@EnableScheduling +public class ConcurrencyConfig { +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java index 54ba616..8e274e3 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java @@ -51,6 +51,10 @@ public class Family { @Column(name = "created_at") private Instant createdAt; + /** Cuándo aceptó la política de privacidad (RGPD); null en cuentas antiguas. */ + @Column(name = "privacy_accepted_at") + private Instant privacyAcceptedAt; + public Family() { // JPA / creación desde el servicio. } @@ -118,4 +122,12 @@ public class Family { public Instant getCreatedAt() { return createdAt; } + + public Instant getPrivacyAcceptedAt() { + return privacyAcceptedAt; + } + + public void setPrivacyAcceptedAt(Instant privacyAcceptedAt) { + this.privacyAcceptedAt = privacyAcceptedAt; + } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/RecoveryCode.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/RecoveryCode.java new file mode 100644 index 0000000..3d19269 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/RecoveryCode.java @@ -0,0 +1,90 @@ +package es.asepeyo.recordalexia.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Código de recuperación de contraseña (el "token" del enlace del email). Se + * guarda SOLO su SHA-256 — el valor real viaja únicamente en el email —, es de un + * solo uso, caduca pronto y emitir uno nuevo elimina los anteriores de la familia. + */ +@Entity +@Table(name = "password_reset_token") +public class RecoveryCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + + /** SHA-256 (hex) del código; nunca el valor en claro. */ + @Column(name = "token_hash", nullable = false, unique = true) + private String tokenHash; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + /** Instante de consumo; null mientras siga siendo utilizable. */ + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected RecoveryCode() { + } + + public RecoveryCode(Family family, String tokenHash, Instant expiresAt, Instant createdAt) { + this.family = family; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + } + + public boolean isUsable(Instant now) { + return usedAt == null && now.isBefore(expiresAt); + } + + public Long getId() { + return id; + } + + public Family getFamily() { + return family; + } + + public String getTokenHash() { + return tokenHash; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java b/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java index 2216bc8..b1a156a 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java @@ -35,4 +35,13 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest() .body(Map.of("error", "bad_request", "message", ex.getMessage())); } + + @ExceptionHandler(TooManyAttemptsException.class) + public ResponseEntity> handleTooManyAttempts(TooManyAttemptsException ex) { + // 429 + Retry-After: frena la fuerza bruta sin revelar nada más. + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header("Retry-After", String.valueOf(ex.getRetryAfterSeconds())) + .body(Map.of("error", "too_many_attempts", + "retryAfterSeconds", ex.getRetryAfterSeconds(), "message", ex.getMessage())); + } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/exception/TooManyAttemptsException.java b/backend/src/main/java/es/asepeyo/recordalexia/exception/TooManyAttemptsException.java new file mode 100644 index 0000000..c91b09d --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/exception/TooManyAttemptsException.java @@ -0,0 +1,20 @@ +package es.asepeyo.recordalexia.exception; + +/** + * Demasiados intentos fallidos (login, PIN o solicitud de reset): la petición se + * rechaza SIN evaluar credenciales. Lleva los segundos de espera para la cabecera + * Retry-After. + */ +public class TooManyAttemptsException extends RuntimeException { + + private final long retryAfterSeconds; + + public TooManyAttemptsException(long retryAfterSeconds) { + super("Demasiados intentos. Espera antes de volver a probar."); + this.retryAfterSeconds = retryAfterSeconds; + } + + public long getRetryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java index e3b5a28..6a967b1 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java @@ -7,4 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface FamilySessionRepository extends JpaRepository { Optional findByHandle(String handle); + + /** Cierra todas las sesiones de una familia (tras restablecer la contraseña). */ + void deleteByFamilyId(Long familyId); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/RecoveryCodeRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/RecoveryCodeRepository.java new file mode 100644 index 0000000..eced795 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/RecoveryCodeRepository.java @@ -0,0 +1,13 @@ +package es.asepeyo.recordalexia.repository; + +import es.asepeyo.recordalexia.domain.RecoveryCode; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecoveryCodeRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + /** Emitir un código nuevo invalida (borra) los anteriores de la familia. */ + void deleteByFamilyId(Long familyId); +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/AccountRecoveryService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/AccountRecoveryService.java new file mode 100644 index 0000000..acad2fd --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/AccountRecoveryService.java @@ -0,0 +1,112 @@ +package es.asepeyo.recordalexia.security; + +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.RecoveryCode; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; +import es.asepeyo.recordalexia.repository.RecoveryCodeRepository; +import es.asepeyo.recordalexia.service.MailService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Recuperación de contraseña por email. Reglas de seguridad: + * - La solicitud responde IGUAL exista o no la cuenta (anti-enumeración); el email + * sale asíncrono para no delatar por tiempos. + * - En BD solo vive el SHA-256 del código; el valor real va únicamente en el enlace. + * - Un solo uso, caducidad de 30 min, y emitir uno nuevo borra los anteriores. + * - Restablecer cierra TODAS las sesiones de la familia (si alguien tenía la + * contraseña vieja, pierde el acceso ya). + */ +@Service +public class AccountRecoveryService { + + static final Duration CODE_TTL = Duration.ofMinutes(30); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final FamilyRepository familyRepository; + private final RecoveryCodeRepository codeRepository; + private final FamilySessionRepository sessionRepository; + private final MailService mailService; + private final PasswordEncoder encoder; + private final Clock clock; + private final String publicBaseUrl; + + public AccountRecoveryService(FamilyRepository familyRepository, + RecoveryCodeRepository codeRepository, + FamilySessionRepository sessionRepository, + MailService mailService, PasswordEncoder encoder, Clock clock, + @Value("${recordalexia.public-base-url:http://localhost:8088}") String publicBaseUrl) { + this.familyRepository = familyRepository; + this.codeRepository = codeRepository; + this.sessionRepository = sessionRepository; + this.mailService = mailService; + this.encoder = encoder; + this.clock = clock; + this.publicBaseUrl = publicBaseUrl; + } + + /** + * Solicita el restablecimiento. NUNCA revela si el email existe: sin cuenta, + * simplemente no pasa nada (y el controlador responde 202 igualmente). + */ + @Transactional + public void requestReset(String email) { + familyRepository.findByEmailIgnoreCase(email.trim().toLowerCase()).ifPresent(family -> { + codeRepository.deleteByFamilyId(family.getId()); + String rawCode = generateCode(); + Instant now = Instant.now(clock); + codeRepository.save(new RecoveryCode(family, sha256(rawCode), now.plus(CODE_TTL), now)); + String link = publicBaseUrl + "/reset?code=" + rawCode; + mailService.sendPasswordReset(family.getEmail(), link); + }); + } + + /** + * Restablece la contraseña con un código vigente y sin usar. Los errores son + * genéricos a propósito: no se distingue "no existe" de "caducado" o "usado". + */ + @Transactional + public void resetPassword(String rawCode, String newPassword) { + if (newPassword == null || newPassword.length() < 6) { + throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres"); + } + Instant now = Instant.now(clock); + RecoveryCode code = codeRepository.findByTokenHash(sha256(rawCode)) + .filter(c -> c.isUsable(now)) + .orElseThrow(() -> new IllegalArgumentException("El enlace no es válido o ha caducado")); + + Family family = code.getFamily(); + family.setPassHash(encoder.encode(newPassword)); + code.setUsedAt(now); + // Cerrar todas las sesiones: quien tuviera acceso con la contraseña vieja, fuera. + sessionRepository.deleteByFamilyId(family.getId()); + } + + /** 32 bytes aleatorios en base64url: apto para viajar en una URL. */ + private String generateCode() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 no disponible", e); + } + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java index 3f4e2e2..720ff40 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java @@ -37,8 +37,10 @@ public class AuthService { if (familyRepository.existsByEmailIgnoreCase(normalized)) { throw new ConflictException("Ese email ya está registrado"); } - Family family = familyRepository.save( - new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin))); + Family family = new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin)); + // El controlador ya exigió la aceptación; aquí queda constancia de cuándo (RGPD). + family.setPrivacyAcceptedAt(java.time.Instant.now()); + family = familyRepository.save(family); // Sembrar el catálogo inicial (materiales + rutinas) para no empezar de cero. initialDataset.provisionFamily(family); return sessions.openSession(family); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/LoginAttemptService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/LoginAttemptService.java new file mode 100644 index 0000000..3e86124 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/LoginAttemptService.java @@ -0,0 +1,100 @@ +package es.asepeyo.recordalexia.security; + +import es.asepeyo.recordalexia.exception.TooManyAttemptsException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * Freno de fuerza bruta en memoria (la app corre como instancia única). Cuenta + * fallos por clave (login:email:ip, unlock:sesión, forgot:email:ip) y, al superar + * el umbral, bloquea temporalmente con backoff exponencial: cada bloqueo sucesivo + * duplica la espera. Con un PIN de 4 dígitos (10.000 combinaciones) este backoff + * convierte el ataque de minutos en días. + * + * La comprobación se hace ANTES de evaluar credenciales: durante un bloqueo ni + * siquiera se toca BCrypt. + */ +@Service +public class LoginAttemptService { + + /** Fallos consecutivos permitidos antes del primer bloqueo. */ + static final int MAX_FAILURES = 5; + /** Primer bloqueo; cada bloqueo sucesivo lo duplica. */ + static final Duration BASE_BLOCK = Duration.ofSeconds(30); + /** Tope del backoff. */ + static final Duration MAX_BLOCK = Duration.ofHours(1); + /** Ventana en la que los fallos cuentan como consecutivos. */ + static final Duration FAILURE_WINDOW = Duration.ofMinutes(15); + /** Antigüedad a partir de la cual una entrada sin bloqueo activo se purga. */ + private static final Duration STALE_AFTER = Duration.ofHours(2); + + /** Estado por clave: fallos en ventana, nº de bloqueos ya impuestos y bloqueo activo. */ + private record Attempts(int failures, int blocks, Instant windowStart, Instant blockedUntil) { + } + + private final ConcurrentHashMap attempts = new ConcurrentHashMap<>(); + private final Clock clock; + + public LoginAttemptService(Clock clock) { + this.clock = clock; + } + + /** Lanza 429 si la clave está bloqueada. Llamar SIEMPRE antes de validar nada. */ + public void checkAllowed(String key) { + Attempts current = attempts.get(key); + if (current != null && current.blockedUntil() != null) { + Instant now = Instant.now(clock); + if (now.isBefore(current.blockedUntil())) { + long seconds = Math.max(1, Duration.between(now, current.blockedUntil()).getSeconds()); + throw new TooManyAttemptsException(seconds); + } + } + } + + /** Registra un fallo; al llegar al umbral impone el siguiente bloqueo del backoff. */ + public void onFailure(String key) { + Instant now = Instant.now(clock); + attempts.compute(key, (k, previous) -> { + int blocks = previous == null ? 0 : previous.blocks(); + int failures = previous == null ? 0 : previous.failures(); + Instant windowStart = previous == null ? now : previous.windowStart(); + // Fallos antiguos no cuentan: se abre ventana nueva. + if (previous == null || windowStart.plus(FAILURE_WINDOW).isBefore(now)) { + failures = 0; + windowStart = now; + } + failures++; + if (failures < MAX_FAILURES) { + return new Attempts(failures, blocks, windowStart, null); + } + // Umbral alcanzado: bloqueo con backoff (30s, 1m, 2m, ... cap 1h). + blocks++; + long factor = 1L << Math.min(blocks - 1, 20); + Duration block = BASE_BLOCK.multipliedBy(factor); + if (block.compareTo(MAX_BLOCK) > 0) { + block = MAX_BLOCK; + } + return new Attempts(0, blocks, now, now.plus(block)); + }); + } + + /** Un acceso correcto perdona el historial de la clave. */ + public void onSuccess(String key) { + attempts.remove(key); + } + + /** Purga horaria de entradas frías (sin bloqueo activo y con ventana antigua). */ + @Scheduled(fixedDelayString = "PT1H") + public void cleanup() { + Instant now = Instant.now(clock); + attempts.entrySet().removeIf(entry -> { + Attempts a = entry.getValue(); + boolean blockActive = a.blockedUntil() != null && now.isBefore(a.blockedUntil()); + return !blockActive && a.windowStart().plus(STALE_AFTER).isBefore(now); + }); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java b/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java index 779039f..f2b5fc7 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java @@ -1,6 +1,7 @@ package es.asepeyo.recordalexia.security; import java.util.List; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -33,15 +34,17 @@ public class SecurityConfig { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter) + public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter, + CorsConfigurationSource corsConfigurationSource) throws Exception { http .csrf(csrf -> csrf.disable()) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .cors(cors -> cors.configurationSource(corsConfigurationSource)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // Públicas: abrir cuenta / sesión. - .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll() + // Públicas: abrir cuenta / sesión / recuperar contraseña. + .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login", + "/api/auth/forgot-password", "/api/auth/reset-password").permitAll() .requestMatchers("/actuator/health").permitAll() // Desbloqueo del panel: basta con tener sesión de familia. .requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY") @@ -53,11 +56,15 @@ public class SecurityConfig { return http.build(); } - /** CORS permisivo para desarrollo (ng serve en otro puerto). En prod va tras Nginx. */ + /** + * Orígenes CORS por configuración: "*" en desarrollo (ng serve en otro puerto); + * en producción, application-prod.yml fija el/los dominios públicos reales. + */ @Bean - public CorsConfigurationSource corsConfigurationSource() { + public CorsConfigurationSource corsConfigurationSource( + @Value("${recordalexia.cors.allowed-origins:*}") List allowedOrigins) { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedOriginPatterns(allowedOrigins); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/MailService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/MailService.java new file mode 100644 index 0000000..69db179 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/MailService.java @@ -0,0 +1,74 @@ +package es.asepeyo.recordalexia.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +/** + * Envío de correo. Con SMTP configurado (spring.mail.host) usa JavaMailSender; + * sin él (desarrollo, CI) escribe el enlace en el log y sigue: el flujo de + * recuperación es probable de extremo a extremo sin infraestructura de correo. + * El envío es @Async para que la respuesta HTTP tarde lo mismo exista o no la + * cuenta (anti-enumeración por timing). + */ +@Service +public class MailService { + + private static final Logger log = LoggerFactory.getLogger(MailService.class); + + private final ObjectProvider mailSender; + private final String from; + + public MailService(ObjectProvider mailSender, + @Value("${recordalexia.mail.from:no-reply@recordalexia.local}") String from) { + this.mailSender = mailSender; + this.from = from; + } + + /** Email de recuperación de contraseña con el enlace de un solo uso. */ + @Async + public void sendPasswordReset(String to, String link) { + JavaMailSender sender = mailSender.getIfAvailable(); + if (sender == null) { + // Modo desarrollo: sin SMTP, el enlace queda en el log del backend. + log.info("[MAIL DEV] Restablecimiento de contraseña para {}: {}", to, link); + return; + } + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject("recordaLexia · Restablecer contraseña / Restablir contrasenya"); + message.setText(""" + Hola, + + Alguien (esperamos que tú) ha pedido restablecer la contraseña de recordaLexia. + El enlace caduca en 30 minutos y solo puede usarse una vez: + + %s + + Si no lo pediste, ignora este mensaje: tu contraseña no cambia. + + — · — + + Hola, + + Algú (esperem que tu) ha demanat restablir la contrasenya de recordaLexia. + L'enllaç caduca en 30 minuts i només es pot fer servir una vegada: + + %s + + Si no ho vas demanar, ignora aquest missatge: la teva contrasenya no canvia. + """.formatted(link, link)); + try { + sender.send(message); + } catch (Exception ex) { + // No propagamos: el emisor es asíncrono y la respuesta HTTP ya fue neutra. + log.error("No se pudo enviar el email de recuperación a {}", to, ex); + } + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java index f018608..4cf2135 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java @@ -3,35 +3,40 @@ package es.asepeyo.recordalexia.web; import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.Language; import es.asepeyo.recordalexia.exception.NotFoundException; +import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.DeleteAccountRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** Preferencias de la cuenta de familia y cambio de credenciales. */ +/** Preferencias de la cuenta de familia, cambio de credenciales y borrado (RGPD). */ @RestController @RequestMapping("/api/account") public class AccountController { private final FamilyContext familyContext; private final FamilyRepository familyRepository; + private final ChildRepository childRepository; private final PasswordEncoder encoder; public AccountController(FamilyContext familyContext, FamilyRepository familyRepository, - PasswordEncoder encoder) { + ChildRepository childRepository, PasswordEncoder encoder) { this.familyContext = familyContext; this.familyRepository = familyRepository; + this.childRepository = childRepository; this.encoder = encoder; } @@ -83,6 +88,24 @@ public class AccountController { return ResponseEntity.noContent().build(); } + /** + * Borrado DEFINITIVO de la cuenta (RGPD: derecho de supresión). Exige la + * contraseña. Primero los niños (su cascada elimina tareas, monedas y canjes + * antes de que caigan los premios) y después la familia (cascada de catálogos, + * premios, sesiones y códigos de recuperación). No queda nada. + */ + @PostMapping("/delete") + @Transactional + public ResponseEntity deleteAccount(@RequestBody DeleteAccountRequest req) { + Family f = current(); + if (req.password() == null || !encoder.matches(req.password(), f.getPassHash())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + childRepository.deleteAll(childRepository.findByFamilyIdOrderByIdAsc(f.getId())); + familyRepository.delete(f); + return ResponseEntity.noContent().build(); + } + private Family current() { return familyRepository.findById(familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("Familia no encontrada")); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java index 838de60..14f039f 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java @@ -3,14 +3,20 @@ package es.asepeyo.recordalexia.web; import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.security.AccountRecoveryService; import es.asepeyo.recordalexia.security.AuthService; import es.asepeyo.recordalexia.security.FamilyContext; +import es.asepeyo.recordalexia.security.LoginAttemptService; import es.asepeyo.recordalexia.security.SessionAuthFilter; import es.asepeyo.recordalexia.security.SessionAuthService; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ForgotPasswordRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse; import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ResetPasswordRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.SessionResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -20,22 +26,31 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** Registro, acceso y sesión de familias. */ +/** Registro, acceso, sesión y recuperación de contraseña de familias. */ @RestController @RequestMapping("/api/auth") public class AuthController { + /** Cuerpo fijo del forgot: idéntico exista o no la cuenta (anti-enumeración). */ + private static final Map FORGOT_BODY = + Map.of("message", "Si el email existe, recibirás instrucciones en unos minutos"); + private final AuthService authService; private final SessionAuthService sessions; private final FamilyContext familyContext; private final FamilyRepository familyRepository; + private final LoginAttemptService attempts; + private final AccountRecoveryService recovery; public AuthController(AuthService authService, SessionAuthService sessions, - FamilyContext familyContext, FamilyRepository familyRepository) { + FamilyContext familyContext, FamilyRepository familyRepository, + LoginAttemptService attempts, AccountRecoveryService recovery) { this.authService = authService; this.sessions = sessions; this.familyContext = familyContext; this.familyRepository = familyRepository; + this.attempts = attempts; + this.recovery = recovery; } /** Alta de familia + auto-login. */ @@ -48,16 +63,49 @@ public class AuthController { if (req.pin() == null || !req.pin().matches("\\d{4}")) { throw new IllegalArgumentException("El PIN debe ser de 4 dígitos"); } + // RGPD: sin aceptación explícita de la política de privacidad no hay cuenta. + if (req.privacyAccepted() == null || !req.privacyAccepted()) { + throw new IllegalArgumentException("Debes aceptar la política de privacidad"); + } String session = authService.register(req.email(), req.password(), req.name(), req.pin()); return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session)); } - /** Acceso con email + contraseña. 200 con sesión, 401 si no. */ + /** Acceso con email + contraseña. 200 con sesión, 401 si no, 429 si fuerza bruta. */ @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest req) { + public ResponseEntity login(@RequestBody LoginRequest req, + HttpServletRequest http) { + // El freno se comprueba ANTES de tocar BCrypt: bloqueado = ni se evalúa. + String key = "login:" + normalize(req.email()) + ":" + clientIp(http); + attempts.checkAllowed(key); return authService.login(req.email(), req.password()) - .map(session -> ResponseEntity.ok(new SessionResponse(session))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + .map(session -> { + attempts.onSuccess(key); + return ResponseEntity.ok(new SessionResponse(session)); + }) + .orElseGet(() -> { + attempts.onFailure(key); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + }); + } + + /** Solicita restablecer contraseña. SIEMPRE 202 con el mismo cuerpo. */ + @PostMapping("/forgot-password") + public ResponseEntity> forgotPassword(@RequestBody ForgotPasswordRequest req, + HttpServletRequest http) { + requireText(req.email(), "email"); + attempts.checkAllowed("forgot:" + normalize(req.email()) + ":" + clientIp(http)); + attempts.onFailure("forgot:" + normalize(req.email()) + ":" + clientIp(http)); + recovery.requestReset(req.email()); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(FORGOT_BODY); + } + + /** Restablece la contraseña con el código del email. 204 o 400 genérico. */ + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest req) { + requireText(req.code(), "code"); + recovery.resetPassword(req.code(), req.newPassword()); + return ResponseEntity.noContent().build(); } /** Cierra la sesión del dispositivo. */ @@ -83,4 +131,17 @@ public class AuthController { throw new IllegalArgumentException("Falta el campo " + field); } } + + private String normalize(String email) { + return email == null ? "" : email.trim().toLowerCase(); + } + + /** IP real del cliente: primer valor de X-Forwarded-For (lo fija nginx) o remota. */ + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java index 992a7fb..2e5f048 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java @@ -2,6 +2,7 @@ package es.asepeyo.recordalexia.web; import es.asepeyo.recordalexia.security.AuthService; import es.asepeyo.recordalexia.security.FamilyContext; +import es.asepeyo.recordalexia.security.LoginAttemptService; import es.asepeyo.recordalexia.security.SessionAuthFilter; import es.asepeyo.recordalexia.security.SessionAuthService; import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest; @@ -21,22 +22,31 @@ public class PanelController { private final AuthService authService; private final SessionAuthService sessions; private final FamilyContext familyContext; + private final LoginAttemptService attempts; public PanelController(AuthService authService, SessionAuthService sessions, - FamilyContext familyContext) { + FamilyContext familyContext, LoginAttemptService attempts) { this.authService = authService; this.sessions = sessions; this.familyContext = familyContext; + this.attempts = attempts; } - /** Valida el PIN y desbloquea el panel para esta sesión. 204 si OK, 401 si no. */ + /** + * Valida el PIN y desbloquea el panel. 204 si OK, 401 si no, 429 si fuerza + * bruta: con solo 10.000 combinaciones, el PIN necesita freno con backoff. + */ @PostMapping("/unlock") public ResponseEntity unlock(@RequestBody UnlockRequest req, @RequestHeader(SessionAuthFilter.HEADER) String handle) { + String key = "unlock:" + handle; + attempts.checkAllowed(key); if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) { + attempts.onSuccess(key); sessions.unlockPanel(handle); return ResponseEntity.noContent().build(); } + attempts.onFailure(key); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java index 82f30b7..ecc0b30 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java @@ -6,7 +6,20 @@ public final class AuthDtos { private AuthDtos() { } - public record RegisterRequest(String email, String password, String name, String pin) { + /** privacyAccepted debe ser true: sin aceptar la política no se crea la cuenta. */ + public record RegisterRequest(String email, String password, String name, String pin, + Boolean privacyAccepted) { + } + + public record ForgotPasswordRequest(String email) { + } + + /** code = valor del enlace del email de recuperación (un solo uso, 30 min). */ + public record ResetPasswordRequest(String code, String newPassword) { + } + + /** Confirmación con contraseña para el borrado definitivo de la cuenta. */ + public record DeleteAccountRequest(String password) { } public record LoginRequest(String email, String password) { diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..d02c8fa --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,19 @@ +# Perfil de PRODUCCIÓN (instancia pública). Se activa con +# SPRING_PROFILES_ACTIVE=prod. Ningún secreto vive aquí: todo llega por variables +# de entorno (.env.prod del compose de producción). +# +# El SMTP se configura ÍNTEGRAMENTE por entorno gracias al binding relajado de +# Spring (no hace falta declararlo en YAML): +# SPRING_MAIL_HOST, SPRING_MAIL_PORT, SPRING_MAIL_USERNAME, SPRING_MAIL_PASSWORD, +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true, +# SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true +# Sin SPRING_MAIL_HOST, MailService cae a modo log (no envía). +recordalexia: + # Dominio público real: obligatorio (sin default a localhost). + public-base-url: ${PUBLIC_BASE_URL} + cors: + # Solo el dominio público; nada de "*" en producción. + allowed-origins: ${CORS_ALLOWED_ORIGINS} + mail: + from: ${MAIL_FROM:no-reply@recordalexia.local} + # seed.enabled NO se declara: fail-safe, la instancia pública jamás siembra demo. diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 102b51d..1985657 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -38,6 +38,19 @@ spring: jackson: time-zone: Europe/Madrid +# --- Propiedades propias de recordaLexia --- +# seed.enabled NO se declara aquí a propósito (fail-safe): sin la propiedad, el +# seeder demo no corre. El docker-compose local y los tests la activan explícito. +recordalexia: + # Base pública para construir enlaces (email de recuperación). En producción la + # fija application-prod.yml con el dominio real. + public-base-url: ${PUBLIC_BASE_URL:http://localhost:8088} + # Orígenes CORS permitidos; "*" SOLO vale para desarrollo (ng serve). + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:*} + mail: + from: ${MAIL_FROM:no-reply@recordalexia.local} + # --- Servidor --- server: port: ${SERVER_PORT:8080} diff --git a/backend/src/main/resources/db/changelog/changes/005-account-recovery.yaml b/backend/src/main/resources/db/changelog/changes/005-account-recovery.yaml new file mode 100644 index 0000000..95f719a --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/005-account-recovery.yaml @@ -0,0 +1,41 @@ +# Recuperación de cuenta (restablecer contraseña) + consentimiento RGPD. +# El token NUNCA se guarda en claro: solo su SHA-256 (un volcado de BD no permite +# usarlo). privacy_accepted_at registra cuándo aceptó la familia la política de +# privacidad (nullable: las cuentas anteriores a esta versión no lo tienen). +databaseChangeLog: + - changeSet: + id: 500-create-reset-token + author: recordalexia + changes: + - createTable: + tableName: password_reset_token + columns: + - column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } } + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - column: { name: token_hash, type: VARCHAR(64), constraints: { nullable: false, unique: true } } + - column: { name: expires_at, type: TIMESTAMP, constraints: { nullable: false } } + - column: { name: used_at, type: TIMESTAMP } + - column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: password_reset_token + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_reset_token_family + onDelete: CASCADE + rollback: + - dropTable: + tableName: password_reset_token + + - changeSet: + id: 501-add-privacy-accepted + author: recordalexia + changes: + - addColumn: + tableName: family + columns: + - column: { name: privacy_accepted_at, type: TIMESTAMP } + rollback: + - dropColumn: + tableName: family + columnName: privacy_accepted_at diff --git a/backend/src/test/java/es/asepeyo/recordalexia/security/LoginAttemptServiceTest.java b/backend/src/test/java/es/asepeyo/recordalexia/security/LoginAttemptServiceTest.java new file mode 100644 index 0000000..ab5e131 --- /dev/null +++ b/backend/src/test/java/es/asepeyo/recordalexia/security/LoginAttemptServiceTest.java @@ -0,0 +1,108 @@ +package es.asepeyo.recordalexia.security; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import es.asepeyo.recordalexia.exception.TooManyAttemptsException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +/** + * Reglas del freno de fuerza bruta, con reloj controlado (sin Spring ni esperas + * reales): umbral exacto, backoff creciente, perdón por éxito y caducidad del bloqueo. + */ +class LoginAttemptServiceTest { + + /** Reloj mutable: los tests avanzan el tiempo a voluntad. */ + private static final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-07-13T10:00:00Z"); + + void advance(Duration d) { + now = now.plus(d); + } + + @Override + public Instant instant() { + return now; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("Europe/Madrid"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + } + + private final MutableClock clock = new MutableClock(); + private final LoginAttemptService service = new LoginAttemptService(clock); + + private void fail(String key, int times) { + for (int i = 0; i < times; i++) { + service.onFailure(key); + } + } + + @Test + void porDebajoDelUmbralNoBloquea() { + fail("login:a@x.com:1.2.3.4", 4); + assertThatCode(() -> service.checkAllowed("login:a@x.com:1.2.3.4")).doesNotThrowAnyException(); + } + + @Test + void alQuintoFalloBloqueaConRetryAfter() { + fail("k", 5); + assertThatThrownBy(() -> service.checkAllowed("k")) + .isInstanceOf(TooManyAttemptsException.class) + .satisfies(ex -> { + long s = ((TooManyAttemptsException) ex).getRetryAfterSeconds(); + org.assertj.core.api.Assertions.assertThat(s).isBetween(1L, 30L); + }); + } + + @Test + void elBloqueoCaducaYlosSucesivosDuplican() { + fail("k", 5); // 1er bloqueo: 30s + clock.advance(Duration.ofSeconds(31)); + assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException(); + + fail("k", 5); // 2º bloqueo: 60s + clock.advance(Duration.ofSeconds(45)); + assertThatThrownBy(() -> service.checkAllowed("k")) + .isInstanceOf(TooManyAttemptsException.class); // 45s < 60s: sigue bloqueado + clock.advance(Duration.ofSeconds(20)); + assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException(); + } + + @Test + void unExitoPerdonaElHistorial() { + fail("k", 4); + service.onSuccess("k"); + fail("k", 4); // sin el perdón, esto sería el 8º fallo y estaría bloqueado + assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException(); + } + + @Test + void losFallosViejosNoCuentan() { + fail("k", 4); + clock.advance(Duration.ofMinutes(16)); // fuera de la ventana de 15 min + fail("k", 4); + assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException(); + } + + @Test + void laLimpiezaPurgaEntradasFrias() { + fail("k", 2); + clock.advance(Duration.ofHours(3)); + service.cleanup(); + // Tras la purga, la clave empieza de cero: 4 fallos no bloquean. + fail("k", 4); + assertThatCode(() -> service.checkAllowed("k")).doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/AccountDeletionIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/AccountDeletionIT.java new file mode 100644 index 0000000..0292d9d --- /dev/null +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/AccountDeletionIT.java @@ -0,0 +1,126 @@ +package es.asepeyo.recordalexia.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import es.asepeyo.recordalexia.domain.AfternoonRoutine; +import es.asepeyo.recordalexia.domain.Child; +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.FamilySession; +import es.asepeyo.recordalexia.domain.RecoveryCode; +import es.asepeyo.recordalexia.domain.RoutineTask; +import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository; +import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.DailyTaskRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; +import es.asepeyo.recordalexia.repository.RecoveryCodeRepository; +import es.asepeyo.recordalexia.repository.RoutineTaskRepository; +import es.asepeyo.recordalexia.service.DayGenerationService; +import es.asepeyo.recordalexia.web.dto.AuthDtos.DeleteAccountRequest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import es.asepeyo.recordalexia.security.SessionAuthFilter; + +/** + * RGPD, derecho de supresión: borrar la cuenta elimina a la familia y TODO lo suyo + * (niños, tareas generadas, rutinas y catálogo, premios, sesiones y códigos de + * recuperación). Nada queda accesible ni almacenado. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class AccountDeletionIT { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private FamilyRepository familyRepository; + @Autowired private ChildRepository childRepository; + @Autowired private RoutineTaskRepository routineTaskRepository; + @Autowired private AfternoonRoutineRepository routineRepository; + @Autowired private DailyTaskRepository dailyTaskRepository; + @Autowired private FamilySessionRepository sessionRepository; + @Autowired private RecoveryCodeRepository codeRepository; + @Autowired private DayGenerationService dayGenerationService; + @Autowired private PasswordEncoder encoder; + @PersistenceContext private EntityManager entityManager; + + @Test + void borrarLaCuentaNoDejaNiRastro() throws Exception { + // Una familia con vida real: niño, rutinas, día generado, premio, sesión y código. + Family f = familyRepository.save(new Family("adios@x.com", encoder.encode("secret123"), + "F", encoder.encode("1234"))); + String handle = "sesion-adios"; + sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS))); + + Child child = new Child(); + child.setFamily(f); + child.setName("Test"); + child.setMascot("🦊"); + child.setAccentColor("#F2A65A"); + child.setAge(8); + child = childRepository.save(child); + Long childId = child.getId(); + + RoutineTask rutina = new RoutineTask("Merendar", "Berenar", "🥪", "#F4C95D"); + rutina.setFamily(f); + rutina = routineTaskRepository.save(rutina); + routineRepository.save(new AfternoonRoutine(child, LocalDate.now().getDayOfWeek(), rutina, 0)); + dayGenerationService.generateIfAbsent(childId, LocalDate.now()); + + codeRepository.save(new RecoveryCode(f, "hash-prueba", Instant.now().plus(30, ChronoUnit.MINUTES), + Instant.now())); + + // Contexto limpio antes del borrado: las cascadas las resuelve la BD. + entityManager.flush(); + entityManager.clear(); + + mockMvc.perform(post("/api/account/delete") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new DeleteAccountRequest("secret123")))) + .andExpect(status().isNoContent()); + // En producción el commit de la petición hace el flush; aquí compartimos + // transacción con el test, así que lo forzamos antes de limpiar el contexto. + entityManager.flush(); + entityManager.clear(); + + // Ni familia, ni niños, ni tareas, ni catálogo, ni sesiones, ni códigos. + Long familyId = f.getId(); + assertThat(familyRepository.findByEmailIgnoreCase("adios@x.com")).isEmpty(); + assertThat(childRepository.findByFamilyIdOrderByIdAsc(familyId)).isEmpty(); + assertThat(routineTaskRepository.findByFamilyId(familyId)).isEmpty(); + assertThat(routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId)).isEmpty(); + assertThat(dailyTaskRepository.existsByChildIdAndTaskDate(childId, LocalDate.now())).isFalse(); + assertThat(sessionRepository.findByHandle(handle)).isEmpty(); + assertThat(codeRepository.count()).isZero(); + } + + @Test + void sinLaContrasenaCorrectaNoSeBorraNada() throws Exception { + Family f = familyRepository.save(new Family("segura@x.com", encoder.encode("secret123"), + "F", encoder.encode("1234"))); + String handle = "sesion-segura"; + sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS))); + + mockMvc.perform(post("/api/account/delete") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new DeleteAccountRequest("incorrecta")))) + .andExpect(status().isUnauthorized()); + assertThat(familyRepository.existsByEmailIgnoreCase("segura@x.com")).isTrue(); + } +} diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/AccountRecoveryIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/AccountRecoveryIT.java new file mode 100644 index 0000000..9876fce --- /dev/null +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/AccountRecoveryIT.java @@ -0,0 +1,148 @@ +package es.asepeyo.recordalexia.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.FamilySession; +import es.asepeyo.recordalexia.domain.RecoveryCode; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; +import es.asepeyo.recordalexia.repository.RecoveryCodeRepository; +import es.asepeyo.recordalexia.service.MailService; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ForgotPasswordRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ResetPasswordRequest; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * Flujo completo de recuperación de contraseña. El MailService va mockeado: el + * enlace (con el código en claro) se captura del argumento, como haría el email. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class AccountRecoveryIT { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private FamilyRepository familyRepository; + @Autowired private RecoveryCodeRepository codeRepository; + @Autowired private FamilySessionRepository sessionRepository; + @Autowired private PasswordEncoder encoder; + @MockitoBean private MailService mailService; + + private Family family(String email) { + return familyRepository.save(new Family(email, encoder.encode("vieja123"), "F", + encoder.encode("1234"))); + } + + private String forgot(String email) throws Exception { + return mockMvc.perform(post("/api/auth/forgot-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ForgotPasswordRequest(email)))) + .andExpect(status().isAccepted()) + .andReturn().getResponse().getContentAsString(); + } + + /** Extrae el código en claro del enlace capturado del "email". */ + private String capturedCode() { + ArgumentCaptor link = ArgumentCaptor.forClass(String.class); + verify(mailService).sendPasswordReset(anyString(), link.capture()); + return link.getValue().substring(link.getValue().indexOf("code=") + 5); + } + + private ResultActionsStatus reset(String code, String newPassword) throws Exception { + int status = mockMvc.perform(post("/api/auth/reset-password") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ResetPasswordRequest(code, newPassword)))) + .andReturn().getResponse().getStatus(); + return new ResultActionsStatus(status); + } + + private record ResultActionsStatus(int status) { + } + + @Test + void emailExistenteEmiteCodigoYemailInexistenteRespondeIgual() throws Exception { + family("existe@x.com"); + + String bodyExiste = forgot("existe@x.com"); + String bodyNoExiste = forgot("nadie@x.com"); + + // Anti-enumeración: cuerpos idénticos; y solo el existente generó código/email. + assertThat(bodyExiste).isEqualTo(bodyNoExiste); + verify(mailService).sendPasswordReset(eq("existe@x.com"), anyString()); + verify(mailService, never()).sendPasswordReset(eq("nadie@x.com"), anyString()); + assertThat(codeRepository.count()).isEqualTo(1); + } + + @Test + void elResetCambiaLaContrasenaYcierraLasSesiones() throws Exception { + Family f = family("reset@x.com"); + sessionRepository.save(new FamilySession("sesion-vieja", f, + Instant.now().plus(1, ChronoUnit.DAYS))); + + forgot("reset@x.com"); + assertThat(reset(capturedCode(), "nueva456").status()).isEqualTo(204); + + // La contraseña nueva entra; la sesión antigua ha muerto. + mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest("reset@x.com", "nueva456")))) + .andExpect(status().isOk()); + assertThat(sessionRepository.findByHandle("sesion-vieja")).isEmpty(); + } + + @Test + void unCodigoNoSePuedeUsarDosVeces() throws Exception { + family("unavez@x.com"); + forgot("unavez@x.com"); + String code = capturedCode(); + + assertThat(reset(code, "nueva456").status()).isEqualTo(204); + assertThat(reset(code, "otra789").status()).isEqualTo(400); + } + + @Test + void unCodigoCaducadoSeRechaza() throws Exception { + family("tarde@x.com"); + forgot("tarde@x.com"); + String code = capturedCode(); + // Forzar la caducidad en BD (más simple y directo que manipular el reloj). + RecoveryCode stored = codeRepository.findAll().get(0); + stored.setExpiresAt(Instant.now().minus(1, ChronoUnit.MINUTES)); + codeRepository.saveAndFlush(stored); + + assertThat(reset(code, "nueva456").status()).isEqualTo(400); + } + + @Test + void pedirUnoNuevoInvalidaElAnterior() throws Exception { + family("dosveces@x.com"); + forgot("dosveces@x.com"); + String primero = capturedCode(); + forgot("dosveces@x.com"); + + // Solo el más reciente vale: el primero ya no existe en BD. + assertThat(reset(primero, "nueva456").status()).isEqualTo(400); + assertThat(codeRepository.count()).isEqualTo(1); + } +} diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java index 71ad0ad..3f74126 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java @@ -37,12 +37,30 @@ class AuthIT { String body = mockMvc.perform(post("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString( - new RegisterRequest(email, "secret123", "Familia", "1234")))) + new RegisterRequest(email, "secret123", "Familia", "1234", true)))) .andExpect(status().isCreated()) .andReturn().getResponse().getContentAsString(); return objectMapper.readTree(body).path("session").asText(); } + @Test + void sinAceptarLaPoliticaNoHayRegistro() throws Exception { + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new RegisterRequest("nop@x.com", "secret123", "Familia", "1234", false)))) + .andExpect(status().isBadRequest()); + org.assertj.core.api.Assertions.assertThat( + familyRepository.existsByEmailIgnoreCase("nop@x.com")).isFalse(); + } + + @Test + void elSeederNoSiembraSinLaPropiedad() { + // Contexto por defecto (sin recordalexia.seed.enabled): fail-safe, sin demo. + org.assertj.core.api.Assertions.assertThat( + familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isEmpty(); + } + @Test void registroAbreSesionYmeDevuelveLaFamilia() throws Exception { String session = register("uno@x.com"); diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/RateLimitIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/RateLimitIT.java new file mode 100644 index 0000000..4f57e2f --- /dev/null +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/RateLimitIT.java @@ -0,0 +1,112 @@ +package es.asepeyo.recordalexia.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.FamilySession; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; +import es.asepeyo.recordalexia.security.SessionAuthFilter; +import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * Freno de fuerza bruta en los puntos sensibles: login (contraseña) y unlock + * (PIN de 4 dígitos). Al 6º fallo consecutivo responde 429 con Retry-After. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class RateLimitIT { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private FamilyRepository familyRepository; + @Autowired private FamilySessionRepository sessionRepository; + @Autowired private PasswordEncoder encoder; + + private Family family(String email) { + return familyRepository.save(new Family(email, encoder.encode("buena123"), "F", + encoder.encode("1234"))); + } + + private void loginFallido(String email) throws Exception { + mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest(email, "malísima")))) + .andExpect(status().isUnauthorized()); + } + + @Test + void elSextoLoginFallidoDevuelve429ConRetryAfter() throws Exception { + family("bruto@x.com"); + for (int i = 0; i < 5; i++) { + loginFallido("bruto@x.com"); + } + mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest("bruto@x.com", "malísima")))) + .andExpect(status().isTooManyRequests()) + .andExpect(header().exists("Retry-After")); + } + + @Test + void unDespisteYlaContrasenaBuenaEntranSinCastigo() throws Exception { + family("despiste@x.com"); + loginFallido("despiste@x.com"); + loginFallido("despiste@x.com"); + mockMvc.perform(post("/api/auth/login").contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest("despiste@x.com", "buena123")))) + .andExpect(status().isOk()); + } + + @Test + void elSextoPinFallidoDevuelve429() throws Exception { + Family f = family("pin@x.com"); + String handle = "sesion-pin-bruta"; + sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS))); + + for (int i = 0; i < 5; i++) { + mockMvc.perform(post("/api/parents/unlock") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UnlockRequest("0000")))) + .andExpect(status().isUnauthorized()); + } + mockMvc.perform(post("/api/parents/unlock") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UnlockRequest("0000")))) + .andExpect(status().isTooManyRequests()) + .andExpect(header().exists("Retry-After")); + } + + @Test + void elPinCorrectoTrasUnFalloDesbloquea() throws Exception { + Family f = family("pinok@x.com"); + String handle = "sesion-pin-ok"; + sessionRepository.save(new FamilySession(handle, f, Instant.now().plus(1, ChronoUnit.DAYS))); + + mockMvc.perform(post("/api/parents/unlock") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UnlockRequest("9999")))) + .andExpect(status().isUnauthorized()); + mockMvc.perform(post("/api/parents/unlock") + .header(SessionAuthFilter.HEADER, handle) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UnlockRequest("1234")))) + .andExpect(status().isNoContent()); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 3022ee3..f720f98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ services: # Spring enlaza estas dos sobre spring.datasource.* por binding relajado. SPRING_DATASOURCE_USERNAME: ${DB_USER} SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} + # Solo en LOCAL: sembrar la familia demo. En producción no se declara + # (el seeder es fail-safe: sin la propiedad, no siembra nada). + RECORDALEXIA_SEED_ENABLED: "true" TZ: Europe/Madrid expose: - "8080"