feat(saas): recuperación de contraseña, freno de fuerza bruta y hardening
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.
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,13 @@ public class GlobalExceptionHandler {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "bad_request", "message", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TooManyAttemptsException.class)
|
||||
public ResponseEntity<Map<String, Object>> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface FamilySessionRepository extends JpaRepository<FamilySession, Long> {
|
||||
|
||||
Optional<FamilySession> findByHandle(String handle);
|
||||
|
||||
/** Cierra todas las sesiones de una familia (tras restablecer la contraseña). */
|
||||
void deleteByFamilyId(Long familyId);
|
||||
}
|
||||
|
||||
@@ -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<RecoveryCode, Long> {
|
||||
|
||||
Optional<RecoveryCode> findByTokenHash(String tokenHash);
|
||||
|
||||
/** Emitir un código nuevo invalida (borra) los anteriores de la familia. */
|
||||
void deleteByFamilyId(Long familyId);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, Attempts> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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();
|
||||
|
||||
@@ -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<JavaMailSender> mailSender;
|
||||
private final String from;
|
||||
|
||||
public MailService(ObjectProvider<JavaMailSender> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Void> 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"));
|
||||
|
||||
@@ -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<String, String> 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<SessionResponse> login(@RequestBody LoginRequest req) {
|
||||
public ResponseEntity<SessionResponse> 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<Map<String, String>> 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<Void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
19
backend/src/main/resources/application-prod.yml
Normal file
19
backend/src/main/resources/application-prod.yml
Normal file
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user