diff --git a/backend/pom.xml b/backend/pom.xml index 21149d0..1142208 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.security + spring-security-crypto + org.springframework.boot spring-boot-starter-webmvc @@ -47,6 +51,11 @@ jackson-databind + + org.springframework.boot + spring-boot-starter-mail + + org.postgresql postgresql diff --git a/backend/src/main/java/com/magicvs/backend/controller/UserController.java b/backend/src/main/java/com/magicvs/backend/controller/UserController.java index 22e3245..cfdf93e 100644 --- a/backend/src/main/java/com/magicvs/backend/controller/UserController.java +++ b/backend/src/main/java/com/magicvs/backend/controller/UserController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.util.Map; @RestController @RequestMapping("/api/users") @@ -18,18 +19,27 @@ public class UserController { private final LoginService loginService; private final AuthService authService; private final RegistroRepository registroRepository; + private final com.magicvs.backend.service.RegistrationVerificationService verificationService; - public UserController(RegistroService registroService, LoginService loginService, AuthService authService, RegistroRepository registroRepository) { + public UserController(RegistroService registroService, LoginService loginService, AuthService authService, RegistroRepository registroRepository, com.magicvs.backend.service.RegistrationVerificationService verificationService) { this.registroService = registroService; this.loginService = loginService; this.authService = authService; this.registroRepository = registroRepository; + this.verificationService = verificationService; + } + + @GetMapping("/exists") + public ResponseEntity> exists(@RequestParam(name = "usernameOrEmail") String usernameOrEmail) { + String value = usernameOrEmail.trim(); + boolean exists = loginService.existsByUsernameOrEmail(value); + return ResponseEntity.ok(Map.of("exists", exists)); } // ---- Endpoints expuestos para Registro y Login ---- @PostMapping("/register") - public ResponseEntity register(@RequestBody RegistroRequest request) { + public ResponseEntity register(@RequestBody RegistroRequest request) { try { User user = registroService.registrar(request.username, request.email, request.password, request.displayName); String token = authService.createSession(user.getId()); @@ -37,12 +47,12 @@ public ResponseEntity register(@RequestBody RegistroRequest reques resp.token = token; return ResponseEntity.status(HttpStatus.CREATED).body(resp); } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); } } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login(@RequestBody LoginRequest request) { try { User user = loginService.login(request.usernameOrEmail, request.password); String token = authService.createSession(user.getId()); @@ -50,7 +60,36 @@ public ResponseEntity login(@RequestBody LoginRequest request) { resp.token = token; return ResponseEntity.ok(resp); } catch (IllegalArgumentException ex) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(java.util.Map.of("message", ex.getMessage())); + } + } + + @PostMapping("/register/initiate") + public ResponseEntity initiate(@RequestBody RegistroRequest request) { + try { + var pending = verificationService.initiate(request.username, request.email, request.password, request.displayName); + return ResponseEntity.ok(java.util.Map.of("pendingId", pending.getId())); + } catch (IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); + } catch (Exception ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : "Error interno al iniciar el registro"; + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(java.util.Map.of("message", msg)); + } + } + + @PostMapping("/register/confirm") + public ResponseEntity confirm(@RequestBody ConfirmRequest request) { + try { + User user = verificationService.confirm(request.pendingId, request.code); + String token = authService.createSession(user.getId()); + UserResponse resp = UserResponse.fromEntity(user); + resp.token = token; + return ResponseEntity.status(HttpStatus.CREATED).body(resp); + } catch (IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(java.util.Map.of("message", ex.getMessage())); + } catch (Exception ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : "Error interno al confirmar registro"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(java.util.Map.of("message", msg)); } } @@ -109,4 +148,9 @@ public static UserResponse fromEntity(User user) { return resp; } } + + public static class ConfirmRequest { + public Long pendingId; + public String code; + } } diff --git a/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java b/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java new file mode 100644 index 0000000..1265c6d --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/model/PendingRegistration.java @@ -0,0 +1,109 @@ +package com.magicvs.backend.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "pending_registrations") +public class PendingRegistration { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String username; + + @Column(nullable = false, length = 100) + private String email; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(name = "verification_hash", length = 255) + private String verificationHash; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "attempts") + private Integer attempts = 0; + + public PendingRegistration() { + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getVerificationHash() { + return verificationHash; + } + + public void setVerificationHash(String verificationHash) { + this.verificationHash = verificationHash; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public Integer getAttempts() { + return attempts; + } + + public void setAttempts(Integer attempts) { + this.attempts = attempts; + } +} diff --git a/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java b/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java new file mode 100644 index 0000000..edd0c50 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/repository/PendingRegistrationRepository.java @@ -0,0 +1,9 @@ +package com.magicvs.backend.repository; + +import com.magicvs.backend.model.PendingRegistration; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PendingRegistrationRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/com/magicvs/backend/service/LoginService.java b/backend/src/main/java/com/magicvs/backend/service/LoginService.java index a3d8049..b119d0f 100644 --- a/backend/src/main/java/com/magicvs/backend/service/LoginService.java +++ b/backend/src/main/java/com/magicvs/backend/service/LoginService.java @@ -5,6 +5,8 @@ import org.springframework.stereotype.Service; import java.util.Locale; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Service public class LoginService { @@ -18,15 +20,26 @@ public LoginService(LoginRepository loginRepository) { public User login(String usernameOrEmail, String password) { String value = usernameOrEmail.trim(); + if (!ValidationUtils.isUsernameOrEmail(value)) { + throw new IllegalArgumentException("Formato de usuario o email inválido"); + } + User user = loginRepository.findByUsername(value) .or(() -> loginRepository.findByEmail(value.toLowerCase(Locale.ROOT))) - .orElseThrow(() -> new IllegalArgumentException("Usuario o contraseña incorrectos")); + .orElseThrow(() -> new IllegalArgumentException("Credenciales incorrectas")); - // Comparación directa por simplicidad (recuerda encriptar en producción) - if (!user.getPasswordHash().equals(password)) { - throw new IllegalArgumentException("Usuario o contraseña incorrectos"); + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (!encoder.matches(password, user.getPasswordHash())) { + throw new IllegalArgumentException("Credenciales incorrectas"); } return user; } + + public boolean existsByUsernameOrEmail(String usernameOrEmail) { + String value = usernameOrEmail.trim(); + boolean byUsername = loginRepository.findByUsername(value).isPresent(); + boolean byEmail = loginRepository.findByEmail(value.toLowerCase(Locale.ROOT)).isPresent(); + return byUsername || byEmail; + } } diff --git a/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java b/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java new file mode 100644 index 0000000..3f13dde --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/service/RegistrationVerificationService.java @@ -0,0 +1,240 @@ +package com.magicvs.backend.service; + +import com.magicvs.backend.model.PendingRegistration; +import com.magicvs.backend.model.User; +import com.magicvs.backend.repository.PendingRegistrationRepository; +import com.magicvs.backend.repository.RegistroRepository; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.mail.javamail.MimeMessageHelper; +import jakarta.mail.internet.MimeMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Optional; +import java.security.SecureRandom; + +@Service +public class RegistrationVerificationService { + + private static final Logger logger = LoggerFactory.getLogger(RegistrationVerificationService.class); + + private final PendingRegistrationRepository pendingRepo; + private final RegistroRepository registroRepository; + private final JavaMailSender mailSender; + + private static final String TAG_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + public RegistrationVerificationService(PendingRegistrationRepository pendingRepo, + RegistroRepository registroRepository, JavaMailSender mailSender) { + this.pendingRepo = pendingRepo; + this.registroRepository = registroRepository; + this.mailSender = mailSender; + } + + public PendingRegistration initiate(String username, String email, String rawPassword, String displayName) { + String normalizedUsername = username.trim(); + String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + + logger.info("[DIAGNOSTIC] Iniciando registro. Usuario: {}, Email destino: {}", normalizedUsername, + normalizedEmail); + + if (!ValidationUtils.isValidUsername(normalizedUsername)) { + throw new IllegalArgumentException("Nombre de usuario inválido"); + } + if (!ValidationUtils.isValidEmail(normalizedEmail)) { + throw new IllegalArgumentException("Email con formato inválido"); + } + if (!ValidationUtils.isValidPassword(rawPassword)) { + throw new IllegalArgumentException("Contraseña inválida"); + } + if (registroRepository.existsByUsername(normalizedUsername)) { + throw new IllegalArgumentException("El nombre de usuario ya está en uso"); + } + if (registroRepository.existsByEmail(normalizedEmail)) { + throw new IllegalArgumentException("El email ya está en uso"); + } + + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + String passwordHash = encoder.encode(rawPassword); + + String code = generateNumericCode(6); + String codeHash = encoder.encode(code); + + PendingRegistration pr = new PendingRegistration(); + pr.setUsername(normalizedUsername); + pr.setEmail(normalizedEmail); + pr.setPasswordHash(passwordHash); + pr.setDisplayName(ValidationUtils.sanitizeDisplayName(displayName != null ? displayName : "")); + pr.setVerificationHash(codeHash); + pr.setExpiresAt(LocalDateTime.now().plusMinutes(15)); + + PendingRegistration saved = pendingRepo.save(pr); + + try { + String fromAddress = System.getenv("SMTP_FROM") != null ? System.getenv("SMTP_FROM") + : "noreply@magicvs.local"; + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(normalizedEmail); + helper.setFrom(fromAddress); + helper.setSubject("⚔️ ¡Ya casi estás! Tu código de activación para MagicVS"); + + String formattedCode = code.substring(0, 3) + "-" + code.substring(3); + + String htmlContent = "" + + "" + + "" + + "" + + " " + + + " " + + "
" + + " " + + " " + + " " + + " " + + "
" + + " MAGICVS" + + + "
" + + " " + + "
" + + "
" + + + "
" + + + "
" + + + "
" + + + "
" + + "
" + + "

Verifica tu chispa

" + + + "

Estás a un paso de dominar el Multiverso. Usa el código para completar tu registro:

" + + + "
" + + + " " + + formattedCode + "" + + "
" + + " Confirmar Registro" + + + "

• El código caducará en 15 minutos.

" + + + "
" + + "
© 2026 MAGICVS ARCANE SYSTEMS
" + + + "
" + + "
" + + ""; + + helper.setText(htmlContent, true); + mailSender.send(mimeMessage); + + } catch (Exception ex) { + logger.error("Error sending verification email", ex); + pendingRepo.delete(saved); + throw new RuntimeException("No se pudo enviar el email: " + ex.getMessage()); + } + return saved; + } + + public User confirm(Long pendingId, String code) { + Optional opt = pendingRepo.findById(pendingId); + if (opt.isEmpty()) + throw new IllegalArgumentException("Registro no encontrado"); + + PendingRegistration pr = opt.get(); + if (pr.getExpiresAt().isBefore(LocalDateTime.now())) { + pendingRepo.delete(pr); + throw new IllegalArgumentException("Código expirado"); + } + + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (!encoder.matches(code, pr.getVerificationHash())) { + pr.setAttempts(pr.getAttempts() + 1); + pendingRepo.save(pr); + throw new IllegalArgumentException("Código incorrecto"); + } + + User user = new User(); + user.setUsername(pr.getUsername()); + user.setEmail(pr.getEmail()); + user.setPasswordHash(pr.getPasswordHash()); + user.setDisplayName(pr.getDisplayName().isBlank() ? pr.getUsername() : pr.getDisplayName()); + user.setFriendTag(generateFriendTag()); + + User savedUser = registroRepository.save(user); + pendingRepo.delete(pr); + + try { + String fromAddress = System.getenv("SMTP_FROM") != null ? System.getenv("SMTP_FROM") + : "noreply@magicvs.local"; + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(savedUser.getEmail()); + helper.setFrom(fromAddress); + helper.setSubject("✨ ¡Bienvenido oficialmente, " + savedUser.getDisplayName() + "!"); + + String welcomeHtml = "" + + + "
" + + "
" + + "
" + + + "
" + + + "
" + + + "
" + + + "
" + + "
" + + "

¡Bienvenido, Caminante!

" + + "

Tu cuenta ha sido activada con éxito. Ya puedes entrar a la Arena.

" + + "
ENTRAR A MAGICVS" + + + "
"; + + helper.setText(welcomeHtml, true); + mailSender.send(mimeMessage); + } catch (Exception e) { + logger.warn("Error enviando bienvenida a {}", savedUser.getEmail()); + } + + return savedUser; + } + + private String generateNumericCode(int len) { + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) + sb.append(RANDOM.nextInt(10)); + return sb.toString(); + } + + private String generateFriendTag() { + String tag; + do { + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < 6; i++) + sb.append(TAG_CHARS.charAt(RANDOM.nextInt(TAG_CHARS.length()))); + tag = sb.toString(); + } while (registroRepository.existsByFriendTag(tag)); + return tag; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java index 6b9c8ca..b8f2288 100644 --- a/backend/src/main/java/com/magicvs/backend/service/RegistroService.java +++ b/backend/src/main/java/com/magicvs/backend/service/RegistroService.java @@ -6,6 +6,8 @@ import java.security.SecureRandom; import java.util.Locale; +import com.magicvs.backend.util.ValidationUtils; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Service public class RegistroService { @@ -20,9 +22,33 @@ public RegistroService(RegistroRepository registroRepository) { } public User registrar(String username, String email, String password, String displayName) { + if (username == null || email == null || password == null) { + throw new IllegalArgumentException("Faltan campos obligatorios"); + } + String normalizedUsername = username.trim(); String normalizedEmail = email.trim().toLowerCase(Locale.ROOT); + if (!ValidationUtils.isValidUsername(normalizedUsername)) { + throw new IllegalArgumentException("Nombre de usuario inválido. Solo letras, números, guion bajo y guion medio (3-30 caracteres)"); + } + if (!ValidationUtils.isValidEmail(normalizedEmail)) { + throw new IllegalArgumentException("Email con formato inválido"); + } + if (!ValidationUtils.isValidPassword(password)) { + throw new IllegalArgumentException("La contraseña debe tener entre 8 y 12 caracteres, al menos una mayúscula, un número y un símbolo"); + } + if (ValidationUtils.containsMaliciousPayload(normalizedUsername) || ValidationUtils.containsMaliciousPayload(normalizedEmail) || ValidationUtils.containsMaliciousPayload(displayName)) { + throw new IllegalArgumentException("Entrada sospechosa detectada"); + } + + // Validate displayName (apodo): disallow spaces and special characters + if (displayName != null && !displayName.isBlank()) { + if (!ValidationUtils.isValidDisplayName(displayName)) { + throw new IllegalArgumentException("Nombre visible inválido. El apodo no puede contener espacios ni caracteres especiales"); + } + } + if (registroRepository.existsByUsername(normalizedUsername)) { throw new IllegalArgumentException("El nombre de usuario ya está en uso"); } @@ -33,9 +59,11 @@ public User registrar(String username, String email, String password, String dis User user = new User(); user.setUsername(normalizedUsername); user.setEmail(normalizedEmail); - // En un entorno real deberías encriptar la contraseña (BCrypt, etc.) - user.setPasswordHash(password); - user.setDisplayName(displayName != null && !displayName.isBlank() ? displayName : normalizedUsername); + // Hash password with BCrypt + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + user.setPasswordHash(encoder.encode(password)); + String safeDisplay = ValidationUtils.sanitizeDisplayName(displayName != null ? displayName : ""); + user.setDisplayName(!safeDisplay.isBlank() ? safeDisplay : normalizedUsername); // Generar friendTag tipo Discord (letras y números), único por usuario user.setFriendTag(generateFriendTag()); diff --git a/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java new file mode 100644 index 0000000..f4775e3 --- /dev/null +++ b/backend/src/main/java/com/magicvs/backend/util/ValidationUtils.java @@ -0,0 +1,61 @@ +package com.magicvs.backend.util; + +import java.util.regex.Pattern; + +public final class ValidationUtils { + + private ValidationUtils() {} + + // allow letters, numbers, spaces, underscore and hyphen (3-30 chars) + private static final Pattern USERNAME = Pattern.compile("^[A-Za-z0-9 _-]{3,30}$"); + private static final Pattern EMAIL = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern PASSWORD = Pattern.compile("^(?=.{8,12}$)(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9]).*$"); + private static final Pattern DISPLAYNAME = Pattern.compile("^[\\p{L}0-9_-]{1,50}$"); + + public static boolean isValidUsername(String username) { + if (username == null) return false; + return USERNAME.matcher(username).matches(); + } + + public static boolean isValidEmail(String email) { + if (email == null) return false; + return EMAIL.matcher(email).matches(); + } + + public static boolean isValidPassword(String password) { + if (password == null) return false; + return PASSWORD.matcher(password).matches(); + } + + public static boolean isUsernameOrEmail(String s) { + return isValidUsername(s) || isValidEmail(s); + } + + public static boolean isValidDisplayName(String name) { + if (name == null) return false; + return DISPLAYNAME.matcher(name).matches(); + } + + public static String sanitizeDisplayName(String input) { + if (input == null) return null; + // Remove HTML tags + String stripped = input.replaceAll("<.*?>", ""); + // Allow letters, numbers, spaces, underscore, hyphen + String cleaned = stripped.replaceAll("[^\\p{L}0-9 _\\-]", ""); + // Trim and limit length + if (cleaned.length() > 50) { + cleaned = cleaned.substring(0, 50); + } + return cleaned.trim(); + } + + public static boolean containsMaliciousPayload(String s) { + if (s == null) return false; + String lower = s.toLowerCase(); + // quick checks for SQL/XSS-ish tokens + if (lower.contains("Iniciar sesión

Accede a tus mazos, historial de partidas y amigos.

-
+
- +
@@ -22,18 +22,35 @@

Iniciar type="password" name="password" [(ngModel)]="password" + (ngModelChange)="onInputChange()" class="w-full h-10 rounded-md bg-surface-container/80 border border-outline-variant/40 px-3 text-sm text-zinc-100 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" placeholder="••••••••" - required />

- -

{{ message }}

-

{{ error }}

-
+ @if (message) { +

+ {{ message }} +

+ } + - + \ No newline at end of file diff --git a/frontend/src/app/features/login/login.ts b/frontend/src/app/features/login/login.ts index b46d59c..35fa4c1 100644 --- a/frontend/src/app/features/login/login.ts +++ b/frontend/src/app/features/login/login.ts @@ -2,7 +2,10 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { finalize, timeout } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; import { Router } from '@angular/router'; +import { isValidEmail } from '../../shared/validation'; @Component({ selector: 'app-login', @@ -15,39 +18,112 @@ export class Login { password = ''; message: string | null = null; error: string | null = null; + loading = false; private readonly apiUrl = 'http://localhost:8080/api/users/login'; - constructor(private http: HttpClient, private router: Router) {} + // inject ChangeDetectorRef and NgZone to force UI updates if needed + constructor( + private http: HttpClient, + private router: Router, + private cdr: ChangeDetectorRef, + private ngZone: NgZone + ) {} onSubmit(): void { this.message = null; this.error = null; + const usernameOrEmailTrimmed = this.usernameOrEmail.trim(); + const passwordTrimmed = this.password.trim(); + + // Only allow login via email (not username) + if (!isValidEmail(usernameOrEmailTrimmed)) { + this.error = 'Introduce un correo válido.'; + return; + } + + if (!passwordTrimmed || passwordTrimmed.length < 8) { + this.error = 'Introduce tu contraseña'; + return; + } + + this.loading = true; this.http .post(this.apiUrl, { - usernameOrEmail: this.usernameOrEmail, - password: this.password, + usernameOrEmail: usernameOrEmailTrimmed, + password: passwordTrimmed, }) + .pipe( + // evita que la petición quede pendiente indefinidamente + timeout(10000), + // asegura que loading vuelva a false siempre (dentro de NgZone) + finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + }) + ) .subscribe({ next: (user) => { - this.message = `Bienvenido, ${user.displayName ?? user.username}!`; - // Guardamos token y usuario en localStorage y navegamos al Home - if (user.token) { - localStorage.setItem('token', user.token); + // backend must return a token on successful auth + if (!user || !user.token) { + // treat missing token as invalid credentials + this.ngZone.run(() => { + this.error = 'Credenciales incorrectas'; + this.cdr.detectChanges(); + }); + return; } + + localStorage.setItem('token', user.token); localStorage.setItem('user', JSON.stringify(user)); - this.router.navigateByUrl('/'); + + this.ngZone.run(() => this.router.navigateByUrl('/')); }, - error: (err: HttpErrorResponse) => { - if (err.status === 401 || err.status === 400) { - this.error = 'Usuario o contraseña incorrectos'; + error: (err: any) => { + if (err && err.name === 'TimeoutError') { + this.ngZone.run(() => { + this.error = 'Tiempo de espera agotado. Intenta de nuevo.'; + this.cdr.detectChanges(); + }); + return; + } + + // Usuario no existe o contraseña incorrecta (u otro error): mensaje genérico + if (err instanceof HttpErrorResponse) { + if (err.status === 0) { + this.ngZone.run(() => { + this.error = 'No se ha podido conectar con el servidor. Comprueba que el backend está en ejecución.'; + this.cdr.detectChanges(); + }); + } else if (err.status >= 400 && err.status < 500) { + // map all client errors to invalid credentials + this.ngZone.run(() => { + this.error = 'Credenciales incorrectas'; + this.cdr.detectChanges(); + }); + } else { + this.ngZone.run(() => { + this.error = 'Error del servidor. Intenta más tarde.'; + this.cdr.detectChanges(); + }); + } } else { - this.error = 'Error al conectar con el servidor'; + this.ngZone.run(() => { + this.error = 'Error de red. Comprueba tu conexión.'; + this.cdr.detectChanges(); + }); } }, }); } + + onInputChange(): void { + this.error = null; + this.message = null; + } } interface UserResponse { @@ -59,4 +135,4 @@ interface UserResponse { token?: string; eloRating: number | null; friendsCount: number | null; -} +} \ No newline at end of file diff --git a/frontend/src/app/features/registro/registro.html b/frontend/src/app/features/registro/registro.html index 89900cb..345d5c3 100644 --- a/frontend/src/app/features/registro/registro.html +++ b/frontend/src/app/features/registro/registro.html @@ -1,62 +1,100 @@
+

Crear cuenta

-

Regístrate para jugar combates 1vs1 y gestionar tus mazos.

+

+ Regístrate para jugar combates 1vs1 y gestionar tus mazos. +

+ +
+
+
+
- +
- -

{{ message }}

-

{{ error }}

+ + @if (message) { +

+ {{ message }} +

+ } + + + @if (error) { +
+ {{ error }} +
+ } +
-
+ \ No newline at end of file diff --git a/frontend/src/app/features/registro/registro.ts b/frontend/src/app/features/registro/registro.ts index 9327104..5b40160 100644 --- a/frontend/src/app/features/registro/registro.ts +++ b/frontend/src/app/features/registro/registro.ts @@ -3,6 +3,9 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; +import { finalize } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; +import { isValidUsername, isValidEmail, isValidPassword, sanitizeDisplayName, containsMaliciousPayload, isValidDisplayName } from '../../shared/validation'; @Component({ selector: 'app-registro', @@ -17,40 +20,102 @@ export class Registro { displayName = ''; message: string | null = null; error: string | null = null; + loading = false; - private readonly apiUrl = 'http://localhost:8080/api/users/register'; + private readonly apiUrlInitiate = 'http://localhost:8080/api/users/register/initiate'; - constructor(private http: HttpClient, private router: Router) {} + constructor(private http: HttpClient, private router: Router, private cdr: ChangeDetectorRef, private ngZone: NgZone) {} onSubmit(): void { this.message = null; this.error = null; + // Frontend validation + if (!isValidUsername(this.username)) { + this.error = 'Usuario inválido. Solo letras, números, espacios, guion bajo o guion medio (3-30 caracteres).'; + return; + } + if (!isValidEmail(this.email)) { + this.error = 'Email con formato inválido.'; + return; + } + if (!isValidPassword(this.password)) { + this.error = 'La contraseña debe tener entre 8 y 12 caracteres, al menos una mayúscula, un número y un símbolo.'; + return; + } + if (containsMaliciousPayload(this.username) || containsMaliciousPayload(this.email) || containsMaliciousPayload(this.displayName)) { + this.error = 'Entrada sospechosa detectada.'; + return; + } + + const safeDisplay = sanitizeDisplayName(this.displayName); + + // validate displayName (apodo) - do not allow spaces + if (safeDisplay && !isValidDisplayName(safeDisplay)) { + this.error = 'Nombre visible inválido. El apodo no puede contener espacios ni caracteres especiales.'; + return; + } + + this.loading = true; this.http - .post(this.apiUrl, { + .post<{ pendingId: number }>(this.apiUrlInitiate, { username: this.username, email: this.email, password: this.password, - displayName: this.displayName, + displayName: safeDisplay, }) + .pipe( + finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + }) + ) .subscribe({ - next: (user) => { - this.message = `Cuenta creada. Bienvenido, ${user.displayName ?? user.username}!`; - if (user.token) { - localStorage.setItem('token', user.token); - } - localStorage.setItem('user', JSON.stringify(user)); - this.router.navigateByUrl('/'); + next: (resp) => { + this.ngZone.run(() => { + // navigate to verification page with pendingId + this.router.navigateByUrl(`/verify/${resp.pendingId}`); + }); }, error: (err: HttpErrorResponse) => { - if (err.status === 400) { - this.error = err.error?.message ?? 'Datos inválidos o ya existentes'; - } else { - this.error = 'Error al conectar con el servidor'; - } + this.ngZone.run(() => { + // Prefer backend message when available + const backendMsg = err.error?.message || err.error?.error || err.error?.detail || null; + if (err.status === 400) { + if (backendMsg) { + // map some known backend messages to friendlier texts + const lower = backendMsg.toLowerCase(); + if (lower.includes('email') && lower.includes('en uso')) { + this.error = 'El correo ya está registrado.'; + } else if (lower.includes('nombre de usuario') && lower.includes('en uso')) { + this.error = 'El nombre de usuario ya está en uso.'; + } else { + this.error = backendMsg; + } + } else { + this.error = 'Datos inválidos o ya existentes'; + } + } else if (err.status === 409) { + this.error = backendMsg ?? 'Recurso en conflicto'; + } else if (err.status === 502) { + this.error = backendMsg ?? 'No se pudo enviar el correo de verificación (SMTP)'; + } else if (err.status === 0) { + this.error = 'No se ha podido conectar con el servidor'; + } else { + this.error = 'Error al conectar con el servidor'; + } + this.cdr.detectChanges(); + }); }, }); } + + onInputChange(): void { + this.error = null; + this.message = null; + } } interface UserResponse { diff --git a/frontend/src/app/features/verification/verification.html b/frontend/src/app/features/verification/verification.html new file mode 100644 index 0000000..b110f92 --- /dev/null +++ b/frontend/src/app/features/verification/verification.html @@ -0,0 +1,65 @@ +
+
+ +
+
+ mail_lock +
+

Verifica tu correo

+
+ +

+ Hemos enviado un código a tu email. Por favor, introdúcelo a continuación para activar tu cuenta. +

+ +
+ +
+ + +
+ + + + + + @if (error) { +
+ {{ error }} +
+ } + +
+

+ ¿No has recibido el código? Revisa tu carpeta de spam o intenta registrarte de nuevo. +

+
+ +
+
+
diff --git a/frontend/src/app/features/verification/verification.ts b/frontend/src/app/features/verification/verification.ts new file mode 100644 index 0000000..754bb63 --- /dev/null +++ b/frontend/src/app/features/verification/verification.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Router, ActivatedRoute } from '@angular/router'; +import { finalize } from 'rxjs/operators'; +import { ChangeDetectorRef, NgZone } from '@angular/core'; + +@Component({ + selector: 'app-verification', + imports: [CommonModule, FormsModule], + templateUrl: './verification.html' +}) +export class Verification { + code = ''; + error: string | null = null; + loading = false; + pendingId: number | null = null; + + private readonly apiUrl = 'http://localhost:8080/api/users/register/confirm'; + + constructor(private http: HttpClient, private router: Router, private route: ActivatedRoute, private cdr: ChangeDetectorRef, private ngZone: NgZone) { + const id = this.route.snapshot.paramMap.get('pendingId'); + if (id) { + this.pendingId = Number(id); + } else { + this.error = 'No se ha encontrado una solicitud de registro pendiente.'; + } + } + + submit(): void { + this.error = null; + if (!this.pendingId) { + this.error = 'Código inválido'; + return; + } + if (!this.code || this.code.trim().length === 0) { + this.error = 'Introduce el código de verificación'; + return; + } + + this.loading = true; + this.http.post(this.apiUrl, { pendingId: this.pendingId, code: this.code.trim() }) + .pipe(finalize(() => { + this.ngZone.run(() => { + this.loading = false; + this.cdr.detectChanges(); + }); + })) + .subscribe({ + next: (user) => { + this.ngZone.run(() => { + // Guardar datos en localstorage + if (user.token) { + localStorage.setItem('token', user.token); + } + localStorage.setItem('user', JSON.stringify(user)); + + // Forzar recarga ligera o navegación para actualizar el estado del layout + this.router.navigateByUrl('/').then(() => { + window.location.reload(); // Asegura que el Nav se refresque con el usuario logueado + }); + }); + }, + error: (err: HttpErrorResponse) => { + this.ngZone.run(() => { + const backendMsg = err.error?.message || null; + if (err.status === 400) { + this.error = backendMsg || 'Código de verificación incorrecto o expirado.'; + } else if (err.status === 0) { + this.error = 'No se ha podido conectar con el servidor'; + } else { + this.error = backendMsg || 'Error inesperado al verificar el código'; + } + this.cdr.detectChanges(); + }); + } + }); + } +} + +interface UserResponse { + id: number; + username: string; + email: string; + displayName: string | null; + friendTag: string; + token?: string; + eloRating: number | null; + friendsCount: number | null; +} diff --git a/frontend/src/app/layouts/main-layout/main-layout.html b/frontend/src/app/layouts/main-layout/main-layout.html index 2f4ca3c..775e68f 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.html +++ b/frontend/src/app/layouts/main-layout/main-layout.html @@ -1,75 +1,107 @@ @@ -84,10 +116,13 @@

Proyecto universitario (DRA)

+ - + \ No newline at end of file diff --git a/frontend/src/app/layouts/main-layout/main-layout.scss b/frontend/src/app/layouts/main-layout/main-layout.scss index e8ab2e1..f0bd0f6 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.scss +++ b/frontend/src/app/layouts/main-layout/main-layout.scss @@ -32,12 +32,12 @@ button.inline-flex, a.inline-flex, button.p-2, .p-2 { flex:0 1 auto; overflow:hidden; margin: 0; - display:flex; - align-items:center; - gap:8px; /* small gap between name and tag */ + display:inline-flex !important; + align-items:baseline; + gap:0 !important; /* remove gap between name and tag */ } -.user-block .display-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;font-size:14px;line-height:1;padding-top:0} -.user-block .friend-tag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;opacity:0.8;font-size:12px;margin-top:0} +.user-block .display-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;font-size:14px;line-height:1;padding-top:0;margin:0} +.user-block .friend-tag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;opacity:0.8;font-size:12px;margin:0;padding-left:6px} /* ensure logout and icon keep their natural size and don't shrink */ .logged-in > button{flex:0 0 auto} .logged-in > button.p-2{margin-left:0} diff --git a/frontend/src/app/layouts/main-layout/main-layout.ts b/frontend/src/app/layouts/main-layout/main-layout.ts index bdd4e20..d904d08 100644 --- a/frontend/src/app/layouts/main-layout/main-layout.ts +++ b/frontend/src/app/layouts/main-layout/main-layout.ts @@ -57,8 +57,15 @@ export class MainLayout { } try { const u = JSON.parse(raw) as StoredUser; - this.displayName = u.displayName ?? u.username; - this.friendTag = u.friendTag ?? null; + // normalize and trim displayName to avoid accidental trailing spaces + const rawName = (u.displayName ?? u.username) as string | undefined; + if (rawName) { + const cleaned = rawName.replace(/\u00A0/g, ' ').trim(); + this.displayName = cleaned || (u.username ?? null); + } else { + this.displayName = u.username ?? null; + } + this.friendTag = (u.friendTag ?? null)?.toString() ?? null; } catch { this.displayName = null; this.friendTag = null; diff --git a/frontend/src/app/shared/validation.ts b/frontend/src/app/shared/validation.ts new file mode 100644 index 0000000..4ca9dc7 --- /dev/null +++ b/frontend/src/app/shared/validation.ts @@ -0,0 +1,48 @@ +export function isValidUsername(username: string): boolean { + if (!username) return false; + // allow letters, numbers, spaces, underscore and hyphen (3-30 chars) + const re = /^[A-Za-z0-9 _-]{3,30}$/; + return re.test(username); +} + +export function isValidEmail(email: string): boolean { + if (!email) return false; + const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; + return re.test(email); +} + +export function isUsernameOrEmail(value: string): boolean { + return isValidUsername(value) || isValidEmail(value); +} + +export function isValidPassword(password: string): boolean { + if (!password) return false; + // 8-12 chars, at least one uppercase, one digit and one symbol + const re = /^(?=.{8,12}$)(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).*$/; + return re.test(password); +} + +export function sanitizeDisplayName(input: string | null | undefined): string { + if (!input) return ''; + // remove tags + const stripped = input.replace(/<.*?>/g, ''); + // allow letters, numbers, spaces, underscore and hyphen + const cleaned = stripped.replace(/[^\p{L}0-9 _-]/gu, ''); + return cleaned.trim().slice(0, 50); +} +export function isValidDisplayName(name: string | null | undefined): boolean { + if (!name) return false; + // disallow spaces in display name (apodo). Allow letters, numbers, underscore and hyphen. + // use unicode letter class for international names + const re = /^[\p{L}0-9_-]{1,50}$/u; + return re.test(name); +} + +export function containsMaliciousPayload(s: string | null | undefined): boolean { + if (!s) return false; + const lower = s.toLowerCase(); + if (lower.includes('