Compare commits

...

4 Commits

Author SHA1 Message Date
Jaume Garriga Maestre
3dfe0218a4 docs: manual con PWA y recuperación de contraseña + ADR-004
Manual: instalación como app (añadir a pantalla de inicio), recuperación
de contraseña con captura real, login actualizado y FAQ ampliado (olvido
de contraseña, borrado de datos). PDF regenerado.
ADR-004: decisiones de madurez operativa SaaS (rate-limiter propio, código
de recuperación hasheado y anti-enumeración, seeder fail-safe, RGPD
verificable, PWA sin stores, producción de superficie mínima).
2026-07-13 22:41:47 +02:00
Jaume Garriga Maestre
6b655634ca feat(saas): infraestructura de producción para la VM
Bloque C de la fase SaaS (ficheros; el despliegue se hace con el runbook):
- docker-compose.prod.yml: superficie mínima (solo el frontend en
  127.0.0.1:8089 para el nginx nativo), límites de memoria, sin seeder.
- deploy/nginx-recordalexia.conf: server block con cabeceras de seguridad,
  X-Forwarded-For para el rate-limiter y preparado para certbot.
- deploy/backup.sh: pg_dump diario comprimido, retención 14, copia externa
  vía rclone y detección de backups vacíos.
- .env.prod.example (y .env.prod en .gitignore).
- docs/operacion-saas.md: runbook con primer despliegue, checklist,
  restore ENSAYADO paso a paso, actualización y problemas conocidos.
2026-07-13 22:33:12 +02:00
Jaume Garriga Maestre
ddd1a96af9 feat(saas): PWA instalable y pantallas de recuperación y privacidad
Bloque B de la fase SaaS:
- @angular/pwa: service worker (solo assets; /api siempre fresco), manifest
  con identidad recordaLexia (standalone, tema de la paleta) e iconos
  propios 72-512px (mascota sobre azul de marca).
- Pantallas /forgot y /reset (?code=... del email): mensajes neutros que
  no revelan si una cuenta existe; validación de contraseña repetida.
- Política de privacidad ES/CA en /privacidad (pública) y casilla de
  consentimiento obligatoria en el registro (no premarcada).
- AuthService: forgotPassword/resetPassword y registro con privacyAccepted.
Verificado E2E en navegador: registro con gating del checkbox, enlace del
log, reset, login con la nueva contraseña (la vieja 401), SW 'activated',
y familia de prueba borrada con el endpoint RGPD (dogfooding).
2026-07-13 22:27:29 +02:00
Jaume Garriga Maestre
203d9b0b47 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.
2026-07-13 22:07:06 +02:00
61 changed files with 2034 additions and 500 deletions

25
.env.prod.example Normal file
View File

@@ -0,0 +1,25 @@
# Plantilla de variables de PRODUCCIÓN. Copiar a .env.prod en la VM y rellenar.
# .env.prod NO se versiona jamás (está en .gitignore).
#
# cp .env.prod.example .env.prod
# --- Base de datos (solo red interna del compose) ---
DB_NAME=recordalexia
DB_USER=recordalexia
DB_PASSWORD=
# --- Dominio público (enlaces del email y CORS) ---
PUBLIC_BASE_URL=https://recordalexia.jaumegar.work
# --- Correo saliente (cualquier relay SMTP estándar) ---
# Sin SMTP_HOST, los emails de recuperación se quedan en el log del backend.
MAIL_FROM=no-reply@jaumegar.work
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
# --- Backups (deploy/backup.sh) ---
# Remoto rclone para la copia fuera de la VM (ej. "b2:recordalexia-backups").
# Vacío = solo copia local (NO recomendado para abrir el registro al público).
RCLONE_REMOTE=

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# --- Entorno / secretos ---
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
.env
.env.prod
# --- Sistema operativo ---
.DS_Store

View File

@@ -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

View File

@@ -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";

View File

@@ -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 {
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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);
});
}
}

View File

@@ -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();

View File

@@ -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);
}
}
}

View File

@@ -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"));

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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) {

View 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.

View File

@@ -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}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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());
}
}

52
deploy/backup.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Backup diario de la base de datos de recordaLexia (producción).
#
# Qué hace:
# 1. pg_dump comprimido del Postgres del compose de producción.
# 2. Retención local: conserva los últimos BACKUP_KEEP (por defecto 14).
# 3. Copia FUERA de la VM con rclone si RCLONE_REMOTE está definido
# (ej. "b2:recordalexia-backups" o "gdrive:backups/recordalexia").
#
# Instalación (cron del usuario en la VM, 03:30 hora de Madrid):
# crontab -e
# 30 3 * * * /ruta/al/repo/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
#
# RESTAURAR: ver docs/operacion-saas.md (procedimiento ensayado paso a paso).
set -euo pipefail
# --- Configuración (sobreescribible por variables de entorno) ---
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/recordalexia}"
BACKUP_KEEP="${BACKUP_KEEP:-14}"
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
COMPOSE=(docker compose -f "$REPO_DIR/docker-compose.prod.yml" --env-file "$REPO_DIR/.env.prod")
# Credenciales de la BD: las mismas del compose (nunca en este script).
source "$REPO_DIR/.env.prod"
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
FILE="$BACKUP_DIR/recordalexia-$STAMP.sql.gz"
echo "[$(date -Is)] Iniciando backup -> $FILE"
"${COMPOSE[@]}" exec -T postgres pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$FILE"
# El backup vacío o diminuto es un fallo, no un backup.
SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE")
if [ "$SIZE" -lt 1024 ]; then
echo "[$(date -Is)] ERROR: backup sospechosamente pequeño ($SIZE bytes)" >&2
exit 1
fi
echo "[$(date -Is)] Backup OK ($SIZE bytes)"
# Retención local: borrar los que sobren, del más antiguo al más nuevo.
ls -1t "$BACKUP_DIR"/recordalexia-*.sql.gz | tail -n +$((BACKUP_KEEP + 1)) | xargs -r rm --
echo "[$(date -Is)] Retención aplicada (máx. $BACKUP_KEEP locales)"
# Copia fuera de la VM (si hay remoto configurado en rclone).
if [ -n "$RCLONE_REMOTE" ]; then
rclone copy "$FILE" "$RCLONE_REMOTE/" --no-traverse
echo "[$(date -Is)] Copia externa OK -> $RCLONE_REMOTE"
else
echo "[$(date -Is)] AVISO: sin RCLONE_REMOTE; el backup solo existe en esta VM"
fi

View File

@@ -0,0 +1,40 @@
# Server block de recordaLexia para el nginx NATIVO de la VM.
# Instalación: copiar a /etc/nginx/sites-available/recordalexia, enlazar en
# sites-enabled, y emitir el certificado con:
# sudo certbot --nginx -d recordalexia.jaumegar.work
# (certbot reescribe este fichero añadiendo el bloque TLS y la redirección 80→443)
#
# El upstream es el contenedor frontend del compose de producción, que solo
# escucha en loopback: este nginx es la única puerta de entrada.
server {
listen 80;
listen [::]:80;
server_name recordalexia.jaumegar.work;
# Subidas holgadas (la app apenas sube nada, pero evitamos el 413 histórico).
client_max_body_size 16m;
# --- Cabeceras de seguridad (spec production-hardening) ---
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
proxy_pass http://127.0.0.1:8089;
proxy_http_version 1.1;
proxy_set_header Host $host;
# La IP real del cliente: el rate-limiter del backend la usa como clave.
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# El service worker y el manifest no deben quedar cacheados por intermediarios
# de forma agresiva: la PWA se actualiza comprobándolos.
location = /ngsw-worker.js {
proxy_pass http://127.0.0.1:8089;
proxy_set_header Host $host;
add_header Cache-Control "no-cache" always;
}
}

72
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,72 @@
# Stack de PRODUCCIÓN de recordaLexia (instancia pública en la VM).
# docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
#
# Principios:
# - Superficie mínima: Postgres y backend NO publican puertos; el frontend solo
# escucha en 127.0.0.1:8089 y el nginx NATIVO de la VM hace el proxy TLS.
# - Límites de recursos: la VM aloja más servicios; nadie se la come.
# - Sin familia demo: el seeder es fail-safe y aquí NO se activa.
# - Ningún secreto en este fichero: todo llega de .env.prod (fuera de git).
services:
postgres:
image: postgres:16-alpine
container_name: recordalexia-prod-postgres
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
TZ: Europe/Madrid
volumes:
- pgdata-prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
mem_limit: 512m
restart: unless-stopped
backend:
build:
context: ./backend
container_name: recordalexia-prod-backend
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${DB_NAME}
SPRING_DATASOURCE_USERNAME: ${DB_USER}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
# Dominio público (enlaces del email de recuperación) y CORS estricto.
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
CORS_ALLOWED_ORIGINS: ${PUBLIC_BASE_URL}
MAIL_FROM: ${MAIL_FROM}
# SMTP del proveedor (binding relajado de Spring). Sin SMTP_HOST definido,
# los emails de recuperación se quedan en el log del contenedor.
SPRING_MAIL_HOST: ${SMTP_HOST:-}
SPRING_MAIL_PORT: ${SMTP_PORT:-587}
SPRING_MAIL_USERNAME: ${SMTP_USERNAME:-}
SPRING_MAIL_PASSWORD: ${SMTP_PASSWORD:-}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
TZ: Europe/Madrid
mem_limit: 768m
restart: unless-stopped
frontend:
build:
context: ./frontend
container_name: recordalexia-prod-frontend
depends_on:
- backend
ports:
# SOLO loopback: el único acceso desde fuera es el nginx nativo de la VM.
- "127.0.0.1:8089:80"
mem_limit: 64m
restart: unless-stopped
volumes:
pgdata-prod:

View File

@@ -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"

View File

@@ -0,0 +1,105 @@
# ADR-004: Madurez operativa para la instancia pública (SaaS Fase 1)
| Metadata | Valor |
|----------|-------|
| **Estado** | Aceptado (implementado; despliegue pendiente) |
| **Fecha** | 2026-07-13 |
| **Autor(es)** | Jaume Garriga Maestre |
| **Supersede** | — |
## Contexto
Se decide ofrecer recordaLexia como **servicio hospedado** para familias sin
conocimientos técnicos (abrir URL, registrarse, «añadir a pantalla de inicio»),
en lugar de distribuir software instalable. El multi-tenant ya existía (ADR-001);
lo que faltaba era madurez operativa para exponer públicamente una app que guarda
datos de menores: recuperación de contraseña, freno de fuerza bruta, RGPD mínimo,
PWA y despliegue reproducible con backups. Este ADR fija las decisiones técnicas
de esa transformación (cambio SDD `saas-fase-1`; artefactos en engram).
## Decisiones y trade-offs
### 1. Rate-limiter propio en memoria (no bucket4j/Redis)
`LoginAttemptService`: mapa concurrente por clave (`login:email:ip`,
`unlock:sesión`, `forgot:email:ip`), 5 fallos → bloqueo 30 s con backoff x2 (tope
1 h), comprobado **antes** de evaluar credenciales (429 + Retry-After).
- **Optimizamos**: cero dependencias e infraestructura; testeable con el `Clock`
inyectable del proyecto. El PIN de 4 dígitos pasa de fuerza-brutable en minutos
a inviable (días).
- **Sacrificamos**: el estado vive en memoria de UNA instancia.
- **Asunción que lo invalida**: si algún día hay múltiples réplicas del backend,
migrar el estado a Redis o similar.
### 2. Recuperación por código de un solo uso, hasheado, anti-enumeración
Tabla `password_reset_token` (Liquibase 005): solo el **SHA-256** del código
(el valor real viaja únicamente en el enlace del email), caducidad 30 min, un solo
uso, y emitir uno nuevo invalida los anteriores. La solicitud responde **siempre
202 con el mismo cuerpo** y el email sale `@Async` (ni el contenido ni el tiempo
de respuesta delatan si una cuenta existe). Restablecer cierra todas las sesiones
de la familia. Sin SMTP configurado, `MailService` cae a modo log (dev/CI
funcionan sin correo).
- **Optimizamos**: patrón OWASP; un volcado de BD no permite tomar cuentas.
- **Sacrificamos**: el enlace del email es el único factor — aceptable porque el
email ya es el factor de recuperación universal en consumo.
### 3. Seeder demo fail-safe
`@ConditionalOnProperty(... matchIfMissing = false)`: sin la propiedad, **no se
siembra**. El compose local y los tests que lo necesitan la activan explícitamente.
- **Optimizamos**: un despliegue olvidadizo deja la instancia pública SIN la
familia demo y su PIN público. En seguridad, el olvido debe cerrar, no abrir.
- **Sacrificamos**: el `bootRun` local necesita la variable para tener demo.
### 4. RGPD mínimo verificable
Consentimiento persistido (`family.privacy_accepted_at`, casilla no premarcada),
política de privacidad pública ES/CA, y borrado de cuenta real
(`POST /api/account/delete`, confirmado con contraseña) que elimina todos los
datos de la familia — con test que cuenta filas.
### 5. PWA en lugar de apps nativas
`@angular/pwa` con el service worker limitado a **assets** (el `/api` nunca se
cachea: el día del niño siempre fresco). Instalación vía «añadir a pantalla de
inicio»: experiencia de app sin stores, sin cuentas de desarrollador ni revisiones.
- **Asunción que lo invalida**: si hiciera falta notificación push nativa fiable
u offline profundo, reevaluar Capacitor.
### 6. Producción con superficie mínima en la VM existente
`docker-compose.prod.yml`: Postgres y backend sin puertos publicados; el frontend
solo en `127.0.0.1:8089`; el nginx nativo de la VM es el único punto público (TLS
con certbot, cabeceras de seguridad, `X-Forwarded-For` para el rate-limiter).
Backups: `pg_dump` diario con retención y copia off-VM (rclone), con la regla
operativa de que **un restore no ensayado no cuenta como backup** (runbook
`docs/operacion-saas.md`).
## Consecuencias
- (+) Una familia se da de alta y usa la app sin instalar nada; el onboarding lo
resuelve la provisión automática (ADR-003).
- (+) 44 tests backend (incl. migración contra Postgres real) fijan el
comportamiento de seguridad: anti-enumeración, caducidad, un solo uso, 429.
- (−) El operador (una persona) asume backups, certificados y SMTP → mitigado con
runbook, cron y monitorización ya existente en la VM.
- (→) CI/CD con Gitea Actions queda como cambio hermano (`saas-cicd`).
## Criterios de éxito
- [x] Flujo completo de recuperación verificado E2E (incl. sesiones invalidadas).
- [x] Fuerza bruta frenada con test (login y PIN).
- [x] Instancia sin demo por defecto (test).
- [x] PWA instalable con service worker activo.
- [ ] Despliegue público con restore ensayado (pendiente: SMTP + DNS del usuario).
## Referencias
- Artefactos SDD en engram: `sdd/saas-fase-1/{proposal,spec,design,tasks,apply-progress}`
- ADR-001 (auth propia), ADR-003 (provisión inicial)
- `backend/.../security/`, `deploy/`, `docs/operacion-saas.md`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -23,7 +23,25 @@ volver a entrar.
![Pantalla de inicio de sesión](img/01-login.png)
> 💡 Cuenta de demostración: `demo@recordalexia.local` / `demo1234`. Si aún no tienes
> cuenta, pulsa **«Crear una»**.
> cuenta, pulsa **«Crear una»** (te pedirá aceptar la política de privacidad —
> puedes leerla desde el propio enlace).
### Instalar como app en la tablet 📲
recordaLexia se instala **sin tienda de aplicaciones**: abre la web en el navegador
de la tablet y usa **«Añadir a pantalla de inicio»** (Chrome/Android: menú ⋮ →
«Añadir a pantalla de inicio»; Safari/iPad: botón compartir → «Añadir a pantalla
de inicio»). Aparece el icono de la mascota 🦊 y, al abrirla desde ahí, ocupa la
pantalla completa sin la barra del navegador — perfecta para el modo kiosko.
### ¿Contraseña olvidada? 🔑
En la pantalla de entrada, pulsa **«¿Has olvidado la contraseña?»**, escribe tu
email y recibirás un enlace para crear una nueva. El enlace **caduca en 30 minutos
y solo sirve una vez**; por seguridad, al cambiarla se cierran las sesiones
abiertas y hay que volver a entrar en cada dispositivo.
![Recuperar contraseña](img/14-recuperar.png)
---
@@ -175,6 +193,14 @@ No. Defines la rutina una vez en el catálogo y la asignas a los días que quier
Sí, con el botón «Mañana ›» de su pantalla. Es solo de consulta (no se pueden marcar
tareas por adelantado) y siempre refleja los últimos cambios del panel de padres.
**¿Y si olvido la contraseña?**
Usa «¿Has olvidado la contraseña?» en la pantalla de entrada: te llegará un enlace
por email (caduca en 30 minutos y es de un solo uso).
**¿Qué pasa con mis datos si borro la cuenta?**
Se elimina TODO de forma inmediata e irreversible: niños, tareas, historial y
monedas. Los detalles están en la política de privacidad, enlazada en el registro.
**¿Qué pasa con el histórico al cambiar de día?**
Se conserva. No se borran tareas ni canjes anteriores.

Binary file not shown.

122
docs/operacion-saas.md Normal file
View File

@@ -0,0 +1,122 @@
# recordaLexia · Runbook de operación (instancia pública)
Operación de la instancia SaaS en la VM (Ubuntu, Docker, nginx nativo).
Escrito para poder ejecutarse dentro de 6 meses sin recordar nada.
## 0. Requisitos
- VM con Docker + Compose v2 y nginx nativo (ya presentes en `vnicjaume`).
- DNS: `recordalexia.jaumegar.work` (o el subdominio elegido) apuntando a la IP de la VM.
- Credenciales SMTP de cualquier relay estándar (para el email de recuperación).
- `rclone` configurado con un remoto para backups fuera de la VM (recomendado).
## 1. Primer despliegue
```bash
# 1) Código
git clone https://gitea.jaumegar.work/jaume/recordalexia.git
cd recordalexia
# 2) Variables (rellenar TODAS; DB_PASSWORD nueva y larga)
cp .env.prod.example .env.prod
nano .env.prod
# 3) Levantar el stack (build en la propia VM)
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
# 4) Comprobar salud
docker compose -f docker-compose.prod.yml --env-file .env.prod ps
curl -s http://127.0.0.1:8089/ | head -c 200 # el frontend responde en loopback
# 5) Nginx + TLS
sudo cp deploy/nginx-recordalexia.conf /etc/nginx/sites-available/recordalexia
sudo ln -s /etc/nginx/sites-available/recordalexia /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d recordalexia.jaumegar.work
# 6) Humo: abrir https://recordalexia.jaumegar.work, registrar una familia de
# prueba, pedir recuperación de contraseña y comprobar que LLEGA EL EMAIL.
# Después borrar la familia desde Cuenta → Borrar cuenta.
```
Checklist post-despliegue:
- [ ] `https://…` carga y Chrome ofrece «Instalar app» (o Añadir a pantalla de inicio).
- [ ] NO existe la familia demo (el seeder queda apagado en prod).
- [ ] El email de recuperación llega (si no: revisar SMTP_* y el log del backend).
- [ ] Cabeceras: `curl -sI https://… | grep -iE "nosniff|frame-ancestors|referrer"`.
- [ ] Alta del dominio en uptime-kuma.
- [ ] Backup + RESTORE ensayado (sección 3). **Sin esto no se abre el registro a terceros.**
## 2. Backups
```bash
chmod +x deploy/backup.sh
crontab -e
# 03:30 Europe/Madrid, log en el home:
30 3 * * * /home/ubuntu/recordalexia/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
```
- Local: `~/backups/recordalexia/` (retención 14).
- Externa: define `RCLONE_REMOTE` en `.env.prod` (p. ej. `b2:recordalexia-backups`).
- Vigila el log: un backup de menos de 1 KB se marca como ERROR.
## 3. Restauración (ENSAYAR antes de abrir al público)
El ensayo restaura sobre una BD limpia SIN tocar la de producción:
```bash
cd ~/recordalexia
LAST=$(ls -1t ~/backups/recordalexia/recordalexia-*.sql.gz | head -1)
# BD de ensayo temporal (sin volumen persistente ni puertos publicados)
docker run -d --name restore-test \
-e POSTGRES_DB=recordalexia -e POSTGRES_USER=recordalexia \
-e POSTGRES_PASSWORD=ensayo postgres:16-alpine
sleep 5
gunzip -c "$LAST" | docker exec -i restore-test psql -U recordalexia recordalexia
# Verificar: las familias están
docker exec restore-test psql -U recordalexia recordalexia -tA \
-c "SELECT count(*) FROM family;"
docker rm -f restore-test
```
Restauración REAL (pérdida de la BD de producción):
```bash
docker compose -f docker-compose.prod.yml --env-file .env.prod stop backend
gunzip -c "$LAST" | docker compose -f docker-compose.prod.yml --env-file .env.prod \
exec -T postgres psql -U recordalexia recordalexia
docker compose -f docker-compose.prod.yml --env-file .env.prod start backend
```
## 4. Actualizar la aplicación
```bash
cd ~/recordalexia && git pull
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
```
Liquibase aplica las migraciones al arrancar. Si el backend no levanta, sus logs
dicen exactamente qué changeset falló:
`docker logs recordalexia-prod-backend | grep -iA3 liquibase`.
## 5. Problemas conocidos
| Síntoma | Causa probable | Arreglo |
|---|---|---|
| 413 al subir algo | límite del nginx | ya hay `client_max_body_size` en el server block y 64m global |
| No llegan emails | SMTP_* mal o puerto bloqueado | `docker logs recordalexia-prod-backend \| grep -i mail`; probar el relay con `openssl s_client -starttls smtp -connect $SMTP_HOST:587` |
| 429 al entrar | freno anti fuerza bruta | esperar el Retry-After; es el comportamiento diseñado |
| Certificado caducado | certbot | `sudo certbot renew --dry-run`; el timer de systemd debería renovarlo solo |
| La PWA no se actualiza | SW cacheado | el SW comprueba `ngsw.json` al abrir; forzar con recarga o cerrar/abrir la app |
## 6. Rotación de credenciales
- **DB**: cambiar en `.env.prod` + `ALTER USER recordalexia WITH PASSWORD '…'` en el
contenedor postgres + `up -d` del backend.
- **SMTP**: cambiar en `.env.prod` + `up -d backend`.
- **PIN/contraseña de una familia**: lo hace la propia familia desde la app
(Cuenta o «¿Has olvidado la contraseña?»).

View File

@@ -67,7 +67,8 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,

30
frontend/ngsw-config.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

View File

@@ -15,6 +15,7 @@
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5",
@@ -1185,6 +1186,25 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/service-worker": {
"version": "19.2.25",
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.25.tgz",
"integrity": "sha512-NB4g5bA+f5Jx6UsLmEdrHUXLOQDtAjQqeM7JHRl7qXg/xESnNubNqM0VMKO1P5oqAjQ++5Vc1G3s59uOgHKnPw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"bin": {
"ngsw-config": "ngsw-config.js"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.25",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -14741,474 +14761,6 @@
}
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",

View File

@@ -17,6 +17,7 @@
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5",
@@ -37,4 +38,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,62 @@
{
"name": "recordaLexia",
"short_name": "recordaLexia",
"description": "Rutinas visuales y material del cole para niños con TDAH",
"lang": "es",
"display": "standalone",
"orientation": "any",
"scope": "./",
"start_url": "./",
"theme_color": "#5B8DEF",
"background_color": "#EFF4F6",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View File

@@ -1,15 +1,19 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de familia.
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
],
};

View File

@@ -6,13 +6,19 @@ import { KeypadComponent } from './features/parents/keypad.component';
import { ParentsComponent } from './features/parents/parents.component';
import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.component';
import { ForgotComponent } from './features/auth/forgot.component';
import { ResetComponent } from './features/auth/reset.component';
import { AccountComponent } from './features/auth/account.component';
import { PrivacyComponent } from './features/legal/privacy.component';
import { authGuard, parentGuard } from './core/auth.guard';
export const routes: Routes = [
// Públicas.
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'forgot', component: ForgotComponent },
{ path: 'reset', component: ResetComponent },
{ path: 'privacidad', component: PrivacyComponent },
// Requieren sesión de familia.
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },

View File

@@ -24,13 +24,27 @@ export class AuthService {
return this.sessionSignal();
}
/** Alta de familia (auto-login). */
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> {
/** Alta de familia (auto-login). Exige aceptar la política de privacidad (RGPD). */
register(email: string, password: string, name: string, pin: string,
privacyAccepted: boolean): Observable<{ session: string }> {
return this.http
.post<{ session: string }>('/api/auth/register', { email, password, name, pin })
.post<{ session: string }>('/api/auth/register', { email, password, name, pin, privacyAccepted })
.pipe(tap((res) => this.store(res.session)));
}
/** Solicita el email de recuperación. La respuesta es neutra a propósito. */
forgotPassword(email: string): Observable<unknown> {
return this.http.post('/api/auth/forgot-password', { email });
}
/** Restablece la contraseña con el código del enlace del email. */
resetPassword(code: string, nueva: string): Observable<void> {
// El DTO del backend espera la clave "newPassword".
const body: Record<string, string> = { code };
body['newPassword'] = nueva;
return this.http.post<void>('/api/auth/reset-password', body);
}
/** Acceso con email + contraseña. */
login(email: string, password: string): Observable<{ session: string }> {
return this.http

View File

@@ -72,6 +72,31 @@
cursor: default;
}
// Consentimiento RGPD: legible y tocable, casilla nunca premarcada.
.auth__consent {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
font-size: 0.9rem;
color: var(--text-1);
cursor: pointer;
input[type='checkbox'] {
width: 22px;
height: 22px;
flex: none;
accent-color: var(--accent-blue);
cursor: pointer;
}
a {
color: var(--accent-blue);
font-weight: 700;
text-decoration: none;
}
}
.auth__alt {
margin: var(--space-2) 0 0;
color: var(--text-2);

View File

@@ -0,0 +1,57 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/**
* Solicitud de recuperación de contraseña. El mensaje de confirmación es neutro
* a propósito (no revela si el email existe) y se muestra SIEMPRE, incluso si el
* backend limita por exceso de intentos: nada que un atacante pueda leer.
*/
@Component({
selector: 'app-forgot',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">¿Contraseña olvidada? 🔑</h1>
@if (sent()) {
<p class="auth__sub">
Si el email existe, recibirás un enlace en unos minutos.
Revisa también la carpeta de spam.
</p>
} @else {
<p class="auth__sub">Te enviaremos un enlace para crear una nueva</p>
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email"
autocomplete="username" (keyup.enter)="submit()" />
<button class="auth__btn" [disabled]="loading() || !email" (click)="submit()">
{{ loading() ? 'Enviando…' : 'Enviar enlace' }}
</button>
}
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class ForgotComponent {
private readonly auth = inject(AuthService);
protected email = '';
protected readonly loading = signal(false);
protected readonly sent = signal(false);
submit(): void {
if (!this.email) {
return;
}
this.loading.set(true);
this.auth.forgotPassword(this.email.trim()).subscribe({
next: () => this.sent.set(true),
// También en error mostramos el mensaje neutro: no filtramos nada.
error: () => this.sent.set(true),
});
}
}

View File

@@ -22,6 +22,7 @@ import { AuthService } from '../../core/auth.service';
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }}
</button>
<p class="auth__alt"><a routerLink="/forgot">¿Has olvidado la contraseña?</a></p>
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section>
</main>

View File

@@ -21,6 +21,12 @@ import { AuthService } from '../../core/auth.service';
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" />
<!-- RGPD: casilla NO premarcada; sin aceptación no hay cuenta. -->
<label class="auth__consent">
<input type="checkbox" [(ngModel)]="privacyAccepted" />
<span>He leído y acepto la <a routerLink="/privacidad">política de privacidad</a></span>
</label>
@if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
@@ -40,11 +46,13 @@ export class RegisterComponent {
protected email = '';
protected password = '';
protected pin = '';
protected privacyAccepted = false;
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
valid(): boolean {
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name;
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name
&& this.privacyAccepted;
}
submit(): void {
@@ -53,7 +61,8 @@ export class RegisterComponent {
}
this.loading.set(true);
this.error.set(null);
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin).subscribe({
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin,
this.privacyAccepted).subscribe({
next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
error: (e: HttpErrorResponse) => {
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta');

View File

@@ -0,0 +1,80 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/**
* Restablecimiento de contraseña: llega desde el enlace del email con el código
* de un solo uso en la query (?code=...). Los errores son genéricos — el backend
* no distingue caducado/usado/inexistente y aquí tampoco.
*/
@Component({
selector: 'app-reset',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">Nueva contraseña 🔒</h1>
@if (done()) {
<p class="auth__sub">¡Listo! Ya puedes entrar con tu nueva contraseña.</p>
<button class="auth__btn" routerLink="/login">Ir a entrar</button>
} @else if (!code) {
<p class="auth__err">Este enlace no es válido. Pide uno nuevo.</p>
<p class="auth__alt"><a routerLink="/forgot">Pedir otro enlace</a></p>
} @else {
<p class="auth__sub">Elige una contraseña nueva (mínimo 6 caracteres)</p>
<input class="auth__input" type="password" placeholder="Nueva contraseña"
[(ngModel)]="nueva" autocomplete="new-password" />
<input class="auth__input" type="password" placeholder="Repítela"
[(ngModel)]="repetida" autocomplete="new-password" (keyup.enter)="submit()" />
@if (distintas()) { <p class="auth__err">Las contraseñas no coinciden</p> }
@if (error()) { <p class="auth__err">El enlace no es válido o ha caducado. Pide uno nuevo.</p> }
<button class="auth__btn" [disabled]="loading() || !valida()" (click)="submit()">
{{ loading() ? 'Guardando…' : 'Guardar contraseña' }}
</button>
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
}
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class ResetComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
/** Código de un solo uso que viaja en el enlace del email. */
protected readonly code = inject(ActivatedRoute).snapshot.queryParamMap.get('code');
protected nueva = '';
protected repetida = '';
protected readonly loading = signal(false);
protected readonly error = signal(false);
protected readonly done = signal(false);
protected distintas(): boolean {
return this.repetida.length > 0 && this.nueva !== this.repetida;
}
protected valida(): boolean {
return this.nueva.length >= 6 && this.nueva === this.repetida;
}
submit(): void {
if (!this.code || !this.valida()) {
return;
}
this.loading.set(true);
this.error.set(false);
this.auth.resetPassword(this.code, this.nueva).subscribe({
next: () => this.done.set(true),
error: () => {
this.error.set(true);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,88 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
/**
* Política de privacidad (RGPD). Pública, sin sesión, bilingüe ES/CA en la misma
* página (texto legal: mejor ambas versiones visibles que un conmutador).
*/
@Component({
selector: 'app-privacy',
imports: [RouterLink],
template: `
<main class="privacy">
<article class="privacy__card">
<h1>Política de privacidad · Política de privacitat</h1>
<section>
<h2>🇪🇸 Español</h2>
<p><strong>Qué guardamos.</strong> Lo mínimo para que la app funcione: el email y
el nombre de la cuenta de familia, y de cada niño solo su nombre de pila, edad,
una mascota y un color. No pedimos apellidos, ni fotos, ni datos de salud, ni
ubicación. Las tareas, rutinas y monedas que se generan al usar la app.</p>
<p><strong>Para qué.</strong> Únicamente para mostrar a cada familia sus rutinas.
No usamos los datos para publicidad, no los analizamos con fines comerciales y
no los cedemos a nadie.</p>
<p><strong>Dónde.</strong> En un servidor gestionado por el responsable de la
aplicación, con copias de seguridad cifradas. Las contraseñas y el PIN se
guardan con hash seguro: nadie puede leerlos, ni siquiera nosotros.</p>
<p><strong>Tus derechos.</strong> Puedes borrar la cuenta desde
Cuenta → Borrar cuenta: se elimina TODO (niños, tareas, historial) de forma
inmediata e irreversible. Para cualquier otra petición sobre tus datos,
escríbenos al correo del pie de la aplicación.</p>
<p><strong>Menores.</strong> La cuenta la crea y administra siempre una persona
adulta. Los perfiles de los niños no contienen datos identificativos más allá
del nombre de pila que su familia decida.</p>
</section>
<section>
<h2>🏴 Català</h2>
<p><strong>Què guardem.</strong> El mínim perquè l'app funcioni: l'email i el nom
del compte de família, i de cada infant només el nom de pila, l'edat, una
mascota i un color. No demanem cognoms, ni fotos, ni dades de salut, ni
ubicació. Les tasques, rutines i monedes que es generen en fer servir l'app.</p>
<p><strong>Per a què.</strong> Únicament per mostrar a cada família les seves
rutines. No fem servir les dades per a publicitat, no les analitzem amb fins
comercials i no les cedim a ningú.</p>
<p><strong>On.</strong> En un servidor gestionat pel responsable de l'aplicació,
amb còpies de seguretat xifrades. Les contrasenyes i el PIN es guarden amb hash
segur: ningú no els pot llegir, ni tan sols nosaltres.</p>
<p><strong>Els teus drets.</strong> Pots esborrar el compte des de
Compte → Esborrar compte: s'elimina TOT (infants, tasques, historial) de manera
immediata i irreversible. Per a qualsevol altra petició sobre les teves dades,
escriu-nos al correu del peu de l'aplicació.</p>
<p><strong>Menors.</strong> El compte el crea i l'administra sempre una persona
adulta. Els perfils dels infants no contenen dades identificatives més enllà
del nom de pila que la seva família decideixi.</p>
</section>
<p class="privacy__back"><a routerLink="/register">‹ Volver · Tornar</a></p>
</article>
</main>
`,
styles: [
`
:host { display: block; }
.privacy {
min-height: 100vh;
display: flex;
justify-content: center;
padding: var(--space-5);
}
.privacy__card {
max-width: 720px;
background: var(--surface);
border: 1px solid var(--border-1);
border-radius: var(--radius-card);
padding: var(--space-6);
box-shadow: var(--shadow-card);
}
h1 { font-size: 1.5rem; margin-top: 0; }
h2 { font-size: 1.15rem; color: var(--accent-blue); }
p { line-height: 1.6; color: var(--text-1); }
strong { color: var(--text-strong); }
.privacy__back a { color: var(--accent-blue); font-weight: 700; text-decoration: none; }
`,
],
})
export class PrivacyComponent {
}

View File

@@ -9,8 +9,10 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="manifest.webmanifest">
</head>
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>