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 --- # --- Entorno / secretos ---
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example. # El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
.env .env
.env.prod
# --- Sistema operativo --- # --- Sistema operativo ---
.DS_Store .DS_Store

View File

@@ -23,6 +23,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web' 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' implementation 'org.liquibase:liquibase-core'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
// H2 en test para los tests rápidos de servicio/web. La verificación de las // 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. * 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 @Component
@ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = false)
public class DataSeeder implements ApplicationRunner { public class DataSeeder implements ApplicationRunner {
private static final String DEMO_EMAIL = "demo@recordalexia.local"; 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") @Column(name = "created_at")
private Instant createdAt; 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() { public Family() {
// JPA / creación desde el servicio. // JPA / creación desde el servicio.
} }
@@ -118,4 +122,12 @@ public class Family {
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; 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() return ResponseEntity.badRequest()
.body(Map.of("error", "bad_request", "message", ex.getMessage())); .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> { public interface FamilySessionRepository extends JpaRepository<FamilySession, Long> {
Optional<FamilySession> findByHandle(String handle); 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)) { if (familyRepository.existsByEmailIgnoreCase(normalized)) {
throw new ConflictException("Ese email ya está registrado"); throw new ConflictException("Ese email ya está registrado");
} }
Family family = familyRepository.save( Family family = new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin));
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. // Sembrar el catálogo inicial (materiales + rutinas) para no empezar de cero.
initialDataset.provisionFamily(family); initialDataset.provisionFamily(family);
return sessions.openSession(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; package es.asepeyo.recordalexia.security;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -33,15 +34,17 @@ public class SecurityConfig {
} }
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter) public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter,
CorsConfigurationSource corsConfigurationSource)
throws Exception { throws Exception {
http http
.csrf(csrf -> csrf.disable()) .csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Públicas: abrir cuenta / sesión. // Públicas: abrir cuenta / sesión / recuperar contraseña.
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login",
"/api/auth/forgot-password", "/api/auth/reset-password").permitAll()
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
// Desbloqueo del panel: basta con tener sesión de familia. // Desbloqueo del panel: basta con tener sesión de familia.
.requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY") .requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY")
@@ -53,11 +56,15 @@ public class SecurityConfig {
return http.build(); 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 @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource(
@Value("${recordalexia.cors.allowed-origins:*}") List<String> allowedOrigins) {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*")); config.setAllowedOriginPatterns(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); config.setAllowedHeaders(List.of("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 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.Family;
import es.asepeyo.recordalexia.domain.Language; import es.asepeyo.recordalexia.domain.Language;
import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.ChildRepository;
import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest; 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 es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; 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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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 @RestController
@RequestMapping("/api/account") @RequestMapping("/api/account")
public class AccountController { public class AccountController {
private final FamilyContext familyContext; private final FamilyContext familyContext;
private final FamilyRepository familyRepository; private final FamilyRepository familyRepository;
private final ChildRepository childRepository;
private final PasswordEncoder encoder; private final PasswordEncoder encoder;
public AccountController(FamilyContext familyContext, FamilyRepository familyRepository, public AccountController(FamilyContext familyContext, FamilyRepository familyRepository,
PasswordEncoder encoder) { ChildRepository childRepository, PasswordEncoder encoder) {
this.familyContext = familyContext; this.familyContext = familyContext;
this.familyRepository = familyRepository; this.familyRepository = familyRepository;
this.childRepository = childRepository;
this.encoder = encoder; this.encoder = encoder;
} }
@@ -83,6 +88,24 @@ public class AccountController {
return ResponseEntity.noContent().build(); 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() { private Family current() {
return familyRepository.findById(familyContext.currentFamilyId()) return familyRepository.findById(familyContext.currentFamilyId())
.orElseThrow(() -> new NotFoundException("Familia no encontrada")); .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.domain.Family;
import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.exception.NotFoundException;
import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.FamilyRepository;
import es.asepeyo.recordalexia.security.AccountRecoveryService;
import es.asepeyo.recordalexia.security.AuthService; import es.asepeyo.recordalexia.security.AuthService;
import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.security.LoginAttemptService;
import es.asepeyo.recordalexia.security.SessionAuthFilter; import es.asepeyo.recordalexia.security.SessionAuthFilter;
import es.asepeyo.recordalexia.security.SessionAuthService; 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.LoginRequest;
import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse; import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse;
import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest; 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 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.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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 @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
public class AuthController { 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 AuthService authService;
private final SessionAuthService sessions; private final SessionAuthService sessions;
private final FamilyContext familyContext; private final FamilyContext familyContext;
private final FamilyRepository familyRepository; private final FamilyRepository familyRepository;
private final LoginAttemptService attempts;
private final AccountRecoveryService recovery;
public AuthController(AuthService authService, SessionAuthService sessions, public AuthController(AuthService authService, SessionAuthService sessions,
FamilyContext familyContext, FamilyRepository familyRepository) { FamilyContext familyContext, FamilyRepository familyRepository,
LoginAttemptService attempts, AccountRecoveryService recovery) {
this.authService = authService; this.authService = authService;
this.sessions = sessions; this.sessions = sessions;
this.familyContext = familyContext; this.familyContext = familyContext;
this.familyRepository = familyRepository; this.familyRepository = familyRepository;
this.attempts = attempts;
this.recovery = recovery;
} }
/** Alta de familia + auto-login. */ /** Alta de familia + auto-login. */
@@ -48,16 +63,49 @@ public class AuthController {
if (req.pin() == null || !req.pin().matches("\\d{4}")) { if (req.pin() == null || !req.pin().matches("\\d{4}")) {
throw new IllegalArgumentException("El PIN debe ser de 4 dígitos"); 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()); String session = authService.register(req.email(), req.password(), req.name(), req.pin());
return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session)); 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") @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()) return authService.login(req.email(), req.password())
.map(session -> ResponseEntity.ok(new SessionResponse(session))) .map(session -> {
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); 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. */ /** Cierra la sesión del dispositivo. */
@@ -83,4 +131,17 @@ public class AuthController {
throw new IllegalArgumentException("Falta el campo " + field); 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.AuthService;
import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.security.FamilyContext;
import es.asepeyo.recordalexia.security.LoginAttemptService;
import es.asepeyo.recordalexia.security.SessionAuthFilter; import es.asepeyo.recordalexia.security.SessionAuthFilter;
import es.asepeyo.recordalexia.security.SessionAuthService; import es.asepeyo.recordalexia.security.SessionAuthService;
import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest; import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest;
@@ -21,22 +22,31 @@ public class PanelController {
private final AuthService authService; private final AuthService authService;
private final SessionAuthService sessions; private final SessionAuthService sessions;
private final FamilyContext familyContext; private final FamilyContext familyContext;
private final LoginAttemptService attempts;
public PanelController(AuthService authService, SessionAuthService sessions, public PanelController(AuthService authService, SessionAuthService sessions,
FamilyContext familyContext) { FamilyContext familyContext, LoginAttemptService attempts) {
this.authService = authService; this.authService = authService;
this.sessions = sessions; this.sessions = sessions;
this.familyContext = familyContext; 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") @PostMapping("/unlock")
public ResponseEntity<Void> unlock(@RequestBody UnlockRequest req, public ResponseEntity<Void> unlock(@RequestBody UnlockRequest req,
@RequestHeader(SessionAuthFilter.HEADER) String handle) { @RequestHeader(SessionAuthFilter.HEADER) String handle) {
String key = "unlock:" + handle;
attempts.checkAllowed(key);
if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) { if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) {
attempts.onSuccess(key);
sessions.unlockPanel(handle); sessions.unlockPanel(handle);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
attempts.onFailure(key);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
} }
} }

View File

@@ -6,7 +6,20 @@ public final class AuthDtos {
private 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) { 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: jackson:
time-zone: Europe/Madrid 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 --- # --- Servidor ---
server: server:
port: ${SERVER_PORT:8080} 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") String body = mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString( .content(objectMapper.writeValueAsString(
new RegisterRequest(email, "secret123", "Familia", "1234")))) new RegisterRequest(email, "secret123", "Familia", "1234", true))))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString(); .andReturn().getResponse().getContentAsString();
return objectMapper.readTree(body).path("session").asText(); 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 @Test
void registroAbreSesionYmeDevuelveLaFamilia() throws Exception { void registroAbreSesionYmeDevuelveLaFamilia() throws Exception {
String session = register("uno@x.com"); 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 enlaza estas dos sobre spring.datasource.* por binding relajado.
SPRING_DATASOURCE_USERNAME: ${DB_USER} SPRING_DATASOURCE_USERNAME: ${DB_USER}
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD} 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 TZ: Europe/Madrid
expose: expose:
- "8080" - "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) ![Pantalla de inicio de sesión](img/01-login.png)
> 💡 Cuenta de demostración: `demo@recordalexia.local` / `demo1234`. Si aún no tienes > 💡 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 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. 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?** **¿Qué pasa con el histórico al cambiar de día?**
Se conserva. No se borran tareas ni canjes anteriores. 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" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
"optimization": false, "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": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10", "@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5", "@fontsource/opendyslexic": "^5.2.5",
@@ -1185,6 +1186,25 @@
"rxjs": "^6.5.3 || ^7.4.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "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": { "node_modules/vite/node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "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": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10", "@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5", "@fontsource/opendyslexic": "^5.2.5",
@@ -37,4 +38,4 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "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 { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor'; import { authInterceptor } from './core/auth.interceptor';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de familia. // 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 { ParentsComponent } from './features/parents/parents.component';
import { LoginComponent } from './features/auth/login.component'; import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.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 { AccountComponent } from './features/auth/account.component';
import { PrivacyComponent } from './features/legal/privacy.component';
import { authGuard, parentGuard } from './core/auth.guard'; import { authGuard, parentGuard } from './core/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
// Públicas. // Públicas.
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent }, { path: 'register', component: RegisterComponent },
{ path: 'forgot', component: ForgotComponent },
{ path: 'reset', component: ResetComponent },
{ path: 'privacidad', component: PrivacyComponent },
// Requieren sesión de familia. // Requieren sesión de familia.
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] }, { path: '', component: ProfileSelectComponent, canActivate: [authGuard] },

View File

@@ -24,13 +24,27 @@ export class AuthService {
return this.sessionSignal(); return this.sessionSignal();
} }
/** Alta de familia (auto-login). */ /** Alta de familia (auto-login). Exige aceptar la política de privacidad (RGPD). */
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> { register(email: string, password: string, name: string, pin: string,
privacyAccepted: boolean): Observable<{ session: string }> {
return this.http 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))); .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. */ /** Acceso con email + contraseña. */
login(email: string, password: string): Observable<{ session: string }> { login(email: string, password: string): Observable<{ session: string }> {
return this.http return this.http

View File

@@ -72,6 +72,31 @@
cursor: default; 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 { .auth__alt {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--text-2); 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()"> <button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }} {{ loading() ? 'Entrando…' : 'Entrar' }}
</button> </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> <p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section> </section>
</main> </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)" <input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" /> [(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> } @if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()"> <button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
@@ -40,11 +46,13 @@ export class RegisterComponent {
protected email = ''; protected email = '';
protected password = ''; protected password = '';
protected pin = ''; protected pin = '';
protected privacyAccepted = false;
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
valid(): boolean { 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 { submit(): void {
@@ -53,7 +61,8 @@ export class RegisterComponent {
} }
this.loading.set(true); this.loading.set(true);
this.error.set(null); 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(['/'])), next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
error: (e: HttpErrorResponse) => { error: (e: HttpErrorResponse) => {
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta'); 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="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="manifest.webmanifest">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body> </body>
</html> </html>