Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions Documentation/DEVOPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,36 +319,52 @@ de verificación serán inutilizables para el usuario final.
| **Credenciales S3 por entorno** (`S3_ACCESS_KEY`/`S3_SECRET_KEY`) | ✅ |
| **Bootstrap del primer MASTER** vía env (`BOOTSTRAP_MASTER_*`), sin logs de credenciales | ✅ |
| **Swagger/OpenAPI desactivado en prod** | ✅ |
| Rate limiting en login/register (in-memory por IP remota) | ✅ (parcial, ver abajo) |
| Rate limiting en login/register (in-memory por IP del cliente vía `X-Forwarded-For`) | ✅ |
| Rotación de logs (tamaño/fecha) | ✅ |
| Límites de memoria/CPU por contenedor | ✅ |
| Actuator expuesto solo `health` e `info` | ✅ |
| Config de votaciones autocreada con valores por defecto si falta | ✅ |
| Datos demo (`DataLoader`) **solo** en perfiles `local`/`demo` | ✅ |
| CSRF desactivado (API stateless con JWT) | ✅ |

### Rate limiting y proxys (IP real del cliente)

El `RateLimitFilter` identifica al cliente usando `X-Forwarded-For`: toma la
**primera** IP de la cabecera (la más cercana al cliente) y, si la cabecera no
viene o viene vacía, cae a `request.getRemoteAddr()`.

Esto se combina con `server.forward-headers-strategy=framework`
(`application-local.properties` y `application-prod.properties`), que hace que
Spring interprete los headers de proxy (`X-Forwarded-For`, `X-Forwarded-Proto`,
`Forwarded`) y genere esquemas/URLs correctos detrás de un terminador TLS.

> ⚠️ **Frontera de confianza:** `X-Forwarded-For` **solo es fiable** cuando la
> app está **detrás de un proxy/reverse-proxy que sobrescribe la cabecera**
> (Caddy, nginx, Traefik, Render…). Si el puerto 8080 se expone **directamente**,
> un cliente puede falsificar la cabecera y saturar el bucket de otra IP o
> esquivar el límite usando IPs inventadas. Por eso, al introducir TLS,
> hay que colocar el proxy delante y dejar que sea él quien fije
> `X-Forwarded-For`, ignorando el valor que traiga el cliente.

### Pendientes / riesgos conocidos (por orden de prioridad)
1. **Sin TLS/HTTPS** — decisión del equipo para esta fase. El tráfico (incluido
login) va en claro. **Antes de exponer credenciales reales o datos reales,
poner un proxy TLS** (Caddy, nginx, Traefik) delante del puerto 8080.
2. **CORS totalmente permisivo** (`*`, con credentials). Pensado para un frontend
aún inexistente; restringir `AllowedOriginPatterns` cuando exista frontend fijo.
3. **Rate limiter in-memory basado en `request.getRemoteAddr()`** — detrás de un
proxy/túnel, todos los clientes comparten la IP. Si se introduce TLS/proxy,
usar el header `X-Forwarded-For`.
4. **JWT sin revocación** — los tokens son válidos hasta `JWT_EXPIRATION`
3. **JWT sin revocación** — los tokens son válidos hasta `JWT_EXPIRATION`
(24 h). Los refresh tokens sí son revocables (BD).
5. **Backups** — en Supabase la BD se respalda por el proveedor (ver §8); el
4. **Backups** — en Supabase la BD se respalda por el proveedor (ver 8); el
bucket R2 se gestiona desde el dashboard. Si se usara Postgres/MInIO local,
habría que planificar dump + copia del bucket.
6. **Tokens de verificación/reset enviados por email en claro** — mitigable con
5. **Tokens de verificación/reset enviados por email en claro** — mitigable con
HTTPS y rotación corta (por defecto expiran).
7. **`GET /files/**` es `permitAll`** — el acceso al contenido se protege a nivel
6. **`GET /files/**` es `permitAll`** — el acceso al contenido se protege a nivel
de servicio (404 si no autorizado) y a la metadata vía `IdentityVisibility`,
pero conviene revisar el mapeo al añadir más endpoints.
8. **`/admin/**` mapeado a `hasRole("MASTER")`** — solo existen `ban`/`unban`;
7. **`/admin/**` mapeado a `hasRole("MASTER")`** — solo existen `ban`/`unban`;
`logs` y `config` siguen pendientes.
9. **`badges.icon` y `users.profile_picture`** siguen en PostgreSQL como `oid`
8. **`badges.icon` y `users.profile_picture`** siguen en PostgreSQL como `oid`
(large objects) — candidatos a migrar a S3 en una iteración futura.

---
Expand Down Expand Up @@ -503,7 +519,7 @@ Base URL: `http://<host>:8080` (sin HTTPS por ahora). Lista detallada en
| Subida/descarga de archivos falla con `S3Exception` | credenciales R2 incorrectas, bucket inexistente o endpoint mal configurado | revisar `S3_*` del `.env`, que el bucket exista en R2 y el API token tenga permisos |
| `The authorization header is malformed` / 403 en R2 | región distinta de `auto` o secret incorrecto | `S3_REGION=auto` + regenerar el token |
| Descargas con error pero la app levanta | `S3StorageService` no pudo crear el bucket (best-effort) | en MinIO dev el bucket se auto-crea; en R2 debe existir antes |
| Login 429 / rate limited a pesar de IPs distintas | rate limiter in-memory por `getRemoteAddr()` | si hay proxy, ver §7.3 |
| Login 429 / rate limited a pesar de IPs distintas | clientes reales tras el mismo proxy comparten IP, o `X-Forwarded-For` vacío | asegurarse de que el proxy sobrescribe la cabecera; si no, ver §7.3 (frontera de confianza) |
| Enlaces de verificación apuntan a `localhost` | `APP_BASE_URL` sin cambiar | fijar URL pública en `Docker/.env` |
| No hay forma de crear el primer admin | `BOOTSTRAP_MASTER_*` vacíos | rellenar y reiniciar la app (es idempotente) |
| Swagger/OpenAPI no responde en prod | desactivado a propósito | esperado; no re-activar en prod |
Expand All @@ -514,14 +530,15 @@ Base URL: `http://<host>:8080` (sin HTTPS por ahora). Lista detallada en

## 14. Pendientes del equipo DevOps (hoja de ruta)

1. **TLS/HTTPS** (Caddy/nginx + ajustar `APP_BASE_URL` a `https://...`).
1. **TLS/HTTPS** (Caddy/nginx + ajustar `APP_BASE_URL` a `https://...`). Al
añadirlo, el proxy debe sobrescribir `X-Forwarded-For` para que el rate
limiter (§7.3) use la IP del cliente real.
2. Revisar **IPv6/IPv4** y el uso del **connection pooler** de Supabase según el host.
3. Definir **backup del bucket R2** (lifecycle/versionado) y revisar los backups
automáticos de Supabase.
4. Montar `/app/logs` como volumen persistente (hoy efímero).
5. Revisar rate limiter para funcionamiento tras proxy (`X-Forwarded-For`).
6. Restringir CORS al frontend real cuando exista.
7. CI/CD: actualmente no hay pipeline; el build es manual (`docker build` +
5. Restringir CORS al frontend real cuando exista.
6. CI/CD: actualmente no hay pipeline; el build es manual (`docker build` +
`docker compose up`). Considerar GitHub Actions (build de imagen + push a
registry + deploy por SSH).
8. Migrar `badges.icon` y `users.profile_picture` (aún `oid` en PostgreSQL) a S3.
7. Migrar `badges.icon` y `users.profile_picture` (aún `oid` en PostgreSQL) a S3.
4 changes: 4 additions & 0 deletions gradox2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableCaching
public class Gradox2Application {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,8 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.gradox2.persistence.entities.Subject;
import com.example.gradox2.persistence.repository.SubjectRepository;
import com.example.gradox2.presentation.dto.subject.SubjectResponse;
import com.example.gradox2.service.exceptions.NotFoundException;
import com.example.gradox2.utils.mapper.SubjectMapper;
import com.example.gradox2.service.interfaces.ISubjectService;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
Expand All @@ -24,21 +21,17 @@
@Tag(name = "Asignaturas", description = "Catálogo de asignaturas y cursos para crear propuestas de archivo")
public class SubjectController {

private final SubjectRepository subjectRepository;
private final ISubjectService subjectService;

public SubjectController(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
public SubjectController(ISubjectService subjectService) {
this.subjectService = subjectService;
}

@GetMapping
@Operation(summary = "Listar asignaturas", description = "Devuelve todas las asignaturas ordenadas por curso y código")
@ApiResponse(responseCode = "200", description = "Lista de asignaturas devuelta")
public ResponseEntity<List<SubjectResponse>> getAllSubjects() {
List<SubjectResponse> subjects = subjectRepository.findAllByOrderByCourseIdAscCodeAsc()
.stream()
.map(SubjectMapper::toSubjectResponse)
.toList();
return ResponseEntity.ok(subjects);
return ResponseEntity.ok(subjectService.listAll());
}

@GetMapping("/{id}")
Expand All @@ -48,8 +41,6 @@ public ResponseEntity<List<SubjectResponse>> getAllSubjects() {
@ApiResponse(responseCode = "404", description = "Asignatura no encontrada")
})
public ResponseEntity<SubjectResponse> getSubject(@PathVariable Long id) {
Subject subject = subjectRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Subject not found"));
return ResponseEntity.ok(SubjectMapper.toSubjectResponse(subject));
return ResponseEntity.ok(subjectService.getById(id));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public class InMemoryRateLimiter {

private final ConcurrentHashMap<String, Deque<Long>> requestBuckets = new ConcurrentHashMap<>();

public void clear() {
requestBuckets.clear();
}

public boolean isAllowed(String key, int maxRequests, long windowMs) {
long now = System.currentTimeMillis();
Deque<Long> bucket = requestBuckets.computeIfAbsent(key, ignored -> new ArrayDeque<>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,16 @@ private String authenticatedIdentity() {
}

private String clientIp(HttpServletRequest request) {
// Do not trust forwarded headers unless a trusted proxy strategy is explicitly configured.
// Confiar en la primera IP de X-Forwarded-For: solo es seguro si un proxy
// de confianza la sobrescribe (server.forward-headers-strategy=framework).
// Si el cliente conecta directo al puerto de la app, puede falsificarla.
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
String firstIp = forwardedFor.split(",")[0].trim();
if (!firstIp.isEmpty()) {
return firstIp;
}
}
return request.getRemoteAddr();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.example.gradox2.service.implementation;

import java.util.List;

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.gradox2.persistence.entities.Subject;
import com.example.gradox2.persistence.repository.SubjectRepository;
import com.example.gradox2.presentation.dto.subject.SubjectResponse;
import com.example.gradox2.service.exceptions.NotFoundException;
import com.example.gradox2.service.interfaces.ISubjectService;
import com.example.gradox2.utils.mapper.SubjectMapper;

@Service
public class SubjectServiceImpl implements ISubjectService {

private final SubjectRepository subjectRepository;

public SubjectServiceImpl(SubjectRepository subjectRepository) {
this.subjectRepository = subjectRepository;
}

@Override
@Transactional(readOnly = true)
@Cacheable("subjects")
public List<SubjectResponse> listAll() {
return subjectRepository.findAllByOrderByCourseIdAscCodeAsc()
.stream()
.map(SubjectMapper::toSubjectResponse)
.toList();
}

@Override
@Transactional(readOnly = true)
@Cacheable(value = "subjects", key = "#id")
public SubjectResponse getById(Long id) {
Subject subject = subjectRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Subject not found"));
return SubjectMapper.toSubjectResponse(subject);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.gradox2.service.interfaces;

import java.util.List;

import com.example.gradox2.presentation.dto.subject.SubjectResponse;

public interface ISubjectService {
List<SubjectResponse> listAll();

SubjectResponse getById(Long id);
}
4 changes: 4 additions & 0 deletions gradox2/src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ spring.servlet.multipart.max-request-size=10MB

app.base-url=${APP_BASE_URL:http://localhost:8080}

# IP y scheme del cliente desde cabeceras X-Forwarded-* (require un proxy de
# confianza delante que las sobrescriba; Render lo garantiza en produccion)
server.forward-headers-strategy=framework

# Orden de los tags en Swagger UI. Springdoc tradicionalmente numera los tags
# (ej. "01. Autenticación") para controlar el orden, pero eso obliga a renumerar
# al insertar una categoría nueva y genera nombres inválidos en los generadores
Expand Down
4 changes: 4 additions & 0 deletions gradox2/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ spring.servlet.multipart.max-request-size=10MB

app.base-url=${APP_BASE_URL}

# IP y scheme del cliente desde cabeceras X-Forwarded-* (require un proxy de
# confianza delante que las sobrescriba; Render lo garantiza en produccion)
server.forward-headers-strategy=framework

# Swagger/OpenAPI desactivado en produccion (los endpoints dejan de existir)
springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.example.gradox2.persistence.repository.RefreshTokenRepository;
import com.example.gradox2.persistence.repository.UserRepository;
import com.example.gradox2.persistence.repository.VerificationTokenRepository;
import com.example.gradox2.security.InMemoryRateLimiter;
import com.fasterxml.jackson.databind.ObjectMapper;

@SpringBootTest
Expand Down Expand Up @@ -50,8 +51,12 @@ class AuthRateLimitIntegrationTest {
@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private InMemoryRateLimiter rateLimiter;

@BeforeEach
void setUp() {
rateLimiter.clear();
passwordResetTokenRepository.deleteAll();
refreshTokenRepository.deleteAll();
verificationTokenRepository.deleteAll();
Expand Down Expand Up @@ -95,16 +100,14 @@ void loginShouldReturnTooManyRequestsAfterFiveFailuresFromSameIp() throws Except
}

@Test
void loginRateLimitShouldIgnoreSpoofedForwardedForHeader() throws Exception {
String remoteIp = "10.0.0.11";

void loginRateLimitUsesFirstIpFromForwardedForHeader() throws Exception {
for (int i = 0; i < 5; i++) {
mockMvc.perform(post("/api/auth/login")
.with(request -> {
request.setRemoteAddr(remoteIp);
request.setRemoteAddr("10.0.0.11");
return request;
})
.header("X-Forwarded-For", "198.51.100." + i)
.header("X-Forwarded-For", "198.51.100.50")
.contentType(MediaType.APPLICATION_JSON)
.content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!"))))
.andExpect(status().isUnauthorized())
Expand All @@ -113,16 +116,32 @@ void loginRateLimitShouldIgnoreSpoofedForwardedForHeader() throws Exception {

mockMvc.perform(post("/api/auth/login")
.with(request -> {
request.setRemoteAddr(remoteIp);
request.setRemoteAddr("10.0.0.11");
return request;
})
.header("X-Forwarded-For", "203.0.113.200")
.header("X-Forwarded-For", "198.51.100.50")
.contentType(MediaType.APPLICATION_JSON)
.content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!"))))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.errorCode").value("RATE_LIMIT_EXCEEDED"));
}

@Test
void distinctForwardedForIpsDoNotShareRateLimitBucket() throws Exception {
for (int i = 0; i < 6; i++) {
mockMvc.perform(post("/api/auth/login")
.with(request -> {
request.setRemoteAddr("10.0.0.12");
return request;
})
.header("X-Forwarded-For", "198.51.100." + i)
.contentType(MediaType.APPLICATION_JSON)
.content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!"))))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.errorCode").value("UNAUTHENTICATED_ACCESS"));
}
}

private String json(Object value) throws Exception {
return objectMapper.writeValueAsString(value);
}
Expand Down
Loading
Loading