diff --git a/FRONTEND_JWT_MIGRATION.txt b/FRONTEND_JWT_MIGRATION.txt new file mode 100644 index 0000000..380f7bc --- /dev/null +++ b/FRONTEND_JWT_MIGRATION.txt @@ -0,0 +1,167 @@ +================================================================================ +MIGRACIÓN REACT NATIVE: AUTENTICACIÓN CON JWT (backend Fercogan) +================================================================================ +Documento para adaptar el frontend (React Native) a los nuevos endpoints y al +uso del token Bearer. Base URL de ejemplo: https://testapp.digitaltelecom.net + +-------------------------------------------------------------------------------- +1. RESUMEN DEL CAMBIO +-------------------------------------------------------------------------------- +- Antes: el "login" usaba GET /api/usuarios/confirmado/{email} sin contraseña y + guardaba solo email en AsyncStorage (inseguro). +- Ahora: el login real es POST /api/usuarios/login con email (username) y + contraseña. El servidor devuelve un JWT que debe enviarse en las peticiones + protegidas: header Authorization: Bearer . +- La mayoría de rutas /api/* (remates, lotes, pujas, etc.) requieren JWT salvo + las excepciones listadas abajo. + +-------------------------------------------------------------------------------- +2. ENDPOINTS PÚBLICOS (sin Authorization) +-------------------------------------------------------------------------------- +POST /api/usuarios/login → login (body JSON) +POST /api/usuarios/registrar → registro +GET /api/usuarios/confirmado/{username} → solo verificación registro/aprobado + (útil en flujo signup; no sustituye login) +GET /auth/verificar/{username} → mismo concepto que confirmado (signup) +GET /auth/existe/{username} → según implementación actual +OPTIONS /** → CORS preflight +/ws/** , /contador/** → según necesidad (actualmente públicos) + +-------------------------------------------------------------------------------- +3. LOGIN (SIGN IN) — REEMPLAZA confirmado + AsyncStorage sin token +-------------------------------------------------------------------------------- +Método: POST +URL: {BASE_URL}/api/usuarios/login +Headers: + Content-Type: application/json + +Body (JSON): +{ + "username": "", + "password": "" +} + +Respuesta 200 (JSON) — guardar estos valores en AsyncStorage: +{ + "token": "", + "tokenType": "Bearer", + "expiresInMs": 86400000, + "username": "...", + "userId": 1, + "rolId": 1, + "authenticated": true, + "confirmed": true +} + +Errores: +- 401 + { "error": "Credenciales inválidas" } → usuario inexistente o contraseña incorrecta +- 403 + { "error": "Usuario pendiente de aprobación" } → existe pero aún no aprobado + +Acciones en el cliente tras 200: +1) AsyncStorage.setItem("authToken", data.token); +2) AsyncStorage.setItem("usuario", data.username); +3) AsyncStorage.setItem("rol", String(data.rolId)); // ya no hace falta GET /rol/{email} +4) AsyncStorage.setItem("isLoggedIn", "true"); +5) Opcional: guardar userId para depuración. + +NO guardar la contraseña en AsyncStorage. + +-------------------------------------------------------------------------------- +4. SESIÓN ACTUAL / USUARIO LOGUEADO +-------------------------------------------------------------------------------- +GET {BASE_URL}/api/usuarios/me +Headers: + Authorization: Bearer + Accept: application/json + +Respuesta 200 ejemplo: +{ + "username": "...", + "userId": 1, + "rolId": 1, + "confirmed": true +} + +Usar al abrir la app para validar que el token sigue siendo válido; si 401, +limpiar AsyncStorage y volver a pantalla de login. + +-------------------------------------------------------------------------------- +5. PETICIONES PROTEGIDAS (remates, pujas, lotes, cabañas, etc.) +-------------------------------------------------------------------------------- +En TODAS las llamadas fetch/axios a /api/... (excepto login, registrar, +confirmado y auth/verificar según corresponda), añadir: + +headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + (await AsyncStorage.getItem("authToken")) +} + +Si el servidor responde 401, el token expiró o es inválido: cerrar sesión en +cliente (borrar authToken, isLoggedIn) y redirigir a login. + +-------------------------------------------------------------------------------- +6. SIGN UP (REGISTRO) — CAMBIOS MÍNIMOS +-------------------------------------------------------------------------------- +- GET /auth/verificar/{email} puede seguir usándose sin token para decidir si + el usuario ya existe o está aprobado (flujo actual). +- POST /api/usuarios/registrar sigue siendo público; body igual que antes + (username=email, password, celular, ci, nombre, rol). + +Tras registro exitoso, el usuario sigue pendiente de aprobación: NO recibirá +JWT válido para app hasta que un admin apruebe; el login con POST /login +devolverá 403 mientras tanto. + +-------------------------------------------------------------------------------- +7. EJEMPLO handleSignIn (lógica sugerida, pseudocódigo) +-------------------------------------------------------------------------------- +async function handleSignIn() { + setLoading(true); + setError(""); + if (!validateEmail(email)) { setError("Email inválido"); setLoading(false); return; } + + const res = await fetch(BASE + "/api/usuarios/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: email, password: password }), + }); + + const data = await res.json().catch(() => ({})); + + if (res.status === 401) { + setError(data.error || "Credenciales inválidas"); + setLoading(false); + return; + } + if (res.status === 403) { + setError(data.error || "Cuenta pendiente de aprobación"); + setLoading(false); + return; + } + if (!res.ok) { + setError("Error de servidor"); + setLoading(false); + return; + } + + await AsyncStorage.setItem("authToken", data.token); + await AsyncStorage.setItem("usuario", data.username); + await AsyncStorage.setItem("rol", String(data.rolId)); + await AsyncStorage.setItem("isLoggedIn", "true"); + navigation.replace("RematesList"); + setLoading(false); +} + +Eliminar: GET confirmado/{email} como sustituto del login, y la llamada +separada a GET /api/usuarios/rol/{email} tras login (los datos vienen en la +respuesta del login). + +-------------------------------------------------------------------------------- +8. VARIABLES DE ENTORNO BACKEND (operaciones) +-------------------------------------------------------------------------------- +En el servidor, definir JWT_SECRET (cadena larga y aleatoria, mínimo 32 bytes +en UTF-8) y opcionalmente JWT_EXPIRATION_MS. El valor por defecto en +application.properties es solo para desarrollo. + +================================================================================ +FIN DEL DOCUMENTO +================================================================================ diff --git a/pom.xml b/pom.xml index 135fa04..18b4f22 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,23 @@ org.springframework.boot spring-boot-starter-security + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + diff --git a/src/main/java/com/example/fercoganbackend/configuration/CorsConfig.java b/src/main/java/com/example/fercoganbackend/configuration/CorsConfig.java index 92b91e2..9a7fb3c 100644 --- a/src/main/java/com/example/fercoganbackend/configuration/CorsConfig.java +++ b/src/main/java/com/example/fercoganbackend/configuration/CorsConfig.java @@ -15,7 +15,7 @@ public class CorsConfig { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // permite todos los endpoints .allowedOrigins("*") // permite todas las apps cliente - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // métodos permitidos + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") // métodos permitidos .allowedHeaders("*"); // cabeceras permitidas } }; diff --git a/src/main/java/com/example/fercoganbackend/configuration/SecurityConfig.java b/src/main/java/com/example/fercoganbackend/configuration/SecurityConfig.java index 4d5de60..d15d342 100644 --- a/src/main/java/com/example/fercoganbackend/configuration/SecurityConfig.java +++ b/src/main/java/com/example/fercoganbackend/configuration/SecurityConfig.java @@ -1,26 +1,26 @@ package com.example.fercoganbackend.configuration; -import com.example.fercoganbackend.service.UsuarioDetailsService; -import jakarta.servlet.http.HttpServletResponse; +import com.example.fercoganbackend.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { - private final UsuarioDetailsService usuarioDetailsService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(UsuarioDetailsService usuarioDetailsService) { - this.usuarioDetailsService = usuarioDetailsService; + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean @@ -39,20 +39,21 @@ public class SecurityConfig { http .securityMatcher("/**") .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/ws/**").permitAll() // WebSocket + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/ws/**").permitAll() .requestMatchers("/auth/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/usuarios/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/usuarios/registrar").permitAll() .requestMatchers("/api/usuarios/confirmado/**").permitAll() - .requestMatchers("/favicon.ico", "/error", "/static/**", "/contador/**", "/api/**").permitAll() - .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/favicon.ico", "/error", "/static/**", "/contador/**").permitAll() + .requestMatchers("/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) - .httpBasic(Customizer.withDefaults()); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - - } - - diff --git a/src/main/java/com/example/fercoganbackend/controller/UserController.java b/src/main/java/com/example/fercoganbackend/controller/UserController.java index 6477a5f..f4aaf36 100644 --- a/src/main/java/com/example/fercoganbackend/controller/UserController.java +++ b/src/main/java/com/example/fercoganbackend/controller/UserController.java @@ -1,15 +1,21 @@ package com.example.fercoganbackend.controller; +import com.example.fercoganbackend.dto.LoginRequest; +import com.example.fercoganbackend.dto.LoginResponse; import com.example.fercoganbackend.entity.Rol; import com.example.fercoganbackend.entity.Roles; import com.example.fercoganbackend.entity.Usuario; import com.example.fercoganbackend.otros.ConfirmadoTF; +import com.example.fercoganbackend.security.JwtService; import com.example.fercoganbackend.service.UsuarioService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -24,6 +30,51 @@ public class UserController { @Autowired private UsuarioService service; + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private JwtService jwtService; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) + ); + Usuario usuario = service.findByUsername(request.getUsername()); + String token = jwtService.generateToken(usuario); + + LoginResponse body = new LoginResponse(); + body.setToken(token); + body.setTokenType("Bearer"); + body.setExpiresInMs(jwtService.getExpirationMs()); + body.setUsername(usuario.getUsername()); + body.setUserId(usuario.getId()); + body.setRolId(usuario.getRol() != null ? usuario.getRol().getId() : null); + body.setAuthenticated(true); + body.setConfirmed(usuario.isAprobado()); + + return ResponseEntity.ok(body); + } catch (BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Credenciales inválidas")); + } catch (DisabledException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "Usuario pendiente de aprobación")); + } + } + + @GetMapping("/me") + public ResponseEntity> me(Authentication authentication) { + Usuario u = service.findByUsername(authentication.getName()); + Map m = new HashMap<>(); + m.put("username", u.getUsername()); + m.put("userId", u.getId()); + m.put("rolId", u.getRol() != null ? u.getRol().getId() : null); + m.put("confirmed", u.isAprobado()); + return ResponseEntity.ok(m); + } // ✅ Obtener todos los usuarios @GetMapping diff --git a/src/main/java/com/example/fercoganbackend/dto/LoginRequest.java b/src/main/java/com/example/fercoganbackend/dto/LoginRequest.java new file mode 100644 index 0000000..d683c7c --- /dev/null +++ b/src/main/java/com/example/fercoganbackend/dto/LoginRequest.java @@ -0,0 +1,11 @@ +package com.example.fercoganbackend.dto; + +public class LoginRequest { + private String username; + private String password; + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} \ No newline at end of file diff --git a/src/main/java/com/example/fercoganbackend/dto/LoginResponse.java b/src/main/java/com/example/fercoganbackend/dto/LoginResponse.java new file mode 100644 index 0000000..9a4cf5a --- /dev/null +++ b/src/main/java/com/example/fercoganbackend/dto/LoginResponse.java @@ -0,0 +1,76 @@ +package com.example.fercoganbackend.dto; + +public class LoginResponse { + private String token; + private String tokenType = "Bearer"; + private long expiresInMs; + private String username; + private Long userId; + private Long rolId; + private boolean authenticated; + private boolean confirmed; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public long getExpiresInMs() { + return expiresInMs; + } + + public void setExpiresInMs(long expiresInMs) { + this.expiresInMs = expiresInMs; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getRolId() { + return rolId; + } + + public void setRolId(Long rolId) { + this.rolId = rolId; + } + + public boolean isAuthenticated() { + return authenticated; + } + + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } + + public boolean isConfirmed() { + return confirmed; + } + + public void setConfirmed(boolean confirmed) { + this.confirmed = confirmed; + } +} diff --git a/src/main/java/com/example/fercoganbackend/security/JwtAuthenticationFilter.java b/src/main/java/com/example/fercoganbackend/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..289447b --- /dev/null +++ b/src/main/java/com/example/fercoganbackend/security/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.example.fercoganbackend.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + final String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + final String jwt = authHeader.substring(7); + try { + final String username = jwtService.extractUsername(jwt); + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (jwtService.isTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (Exception ignored) { + // Token inválido o expirado: no se establece autenticación + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/fercoganbackend/security/JwtService.java b/src/main/java/com/example/fercoganbackend/security/JwtService.java new file mode 100644 index 0000000..45c2b29 --- /dev/null +++ b/src/main/java/com/example/fercoganbackend/security/JwtService.java @@ -0,0 +1,87 @@ +package com.example.fercoganbackend.security; + +import com.example.fercoganbackend.entity.Usuario; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Service +public class JwtService { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration-ms}") + private long expirationMs; + + public long getExpirationMs() { + return expirationMs; + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(Usuario usuario) { + Map extra = new HashMap<>(); + extra.put("uid", usuario.getId()); + if (usuario.getRol() != null) { + extra.put("rid", usuario.getRol().getId()); + } + extra.put("confirmed", usuario.isAprobado()); + return buildToken(extra, usuario.getUsername()); + } + + private String buildToken(Map extraClaims, String subject) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationMs); + return Jwts.builder() + .claims(extraClaims) + .subject(subject) + .issuedAt(now) + .expiration(expiry) + .signWith(getSigningKey()) + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return username.equals(userDetails.getUsername()) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey getSigningKey() { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + throw new IllegalStateException("jwt.secret debe tener al menos 32 bytes (UTF-8)"); + } + return Keys.hmacShaKeyFor(bytes); + } +} diff --git a/src/main/java/com/example/fercoganbackend/service/UsuarioService.java b/src/main/java/com/example/fercoganbackend/service/UsuarioService.java index 0dc53ca..3be9aad 100644 --- a/src/main/java/com/example/fercoganbackend/service/UsuarioService.java +++ b/src/main/java/com/example/fercoganbackend/service/UsuarioService.java @@ -102,6 +102,11 @@ public class UsuarioService { .orElse(null); // O podrías lanzar una excepción si el usuario no existe } + public Usuario findByUsername(String username) { + return repo.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("Usuario no encontrado: " + username)); + } + @Transactional // Asegura la integridad de los datos public Usuario actualizarParcial(Long id, UserController.UsuarioRequest request) { // 1. Buscamos el usuario actual diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 03fcd84..0a3bd00 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,7 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect logging.level.org.springframework.security=DEBUG logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE + +# JWT (cambiar jwt.secret en producción; mínimo 256 bits para HS256) +jwt.secret=${JWT_SECRET:change-this-in-production-use-long-random-string-at-least-32-chars} +jwt.expiration-ms=${JWT_EXPIRATION_MS:86400000}