diff --git a/.gitignore b/.gitignore index 86ea0ed..ac07912 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ # copias de trabajo *.bak index.css.puntuacion0 + +# el binario compilado para el servidor +despliegue/enrecal diff --git a/despliegue/README.md b/despliegue/README.md new file mode 100644 index 0000000..f43c350 --- /dev/null +++ b/despliegue/README.md @@ -0,0 +1,69 @@ +# Despliegue de /pedir/ + +El gestor de reservas es un programa en Go que escucha en `127.0.0.1:8090`. +nginx le pasa `/pedir/` y sigue sirviendo el resto de la web como hasta ahora. + +| Qué | Dónde | +|---|---| +| programa y plantillas | `/opt/enrecal/` | +| configuración | `/etc/enrecal/config.json` | +| datos (registro, solo crece) | `/var/lib/enrecal/registro.jsonl` | +| accesos, para fail2ban | `/var/log/enrecal/auth.log` | +| servicio | `enrecal.service`, usuario `enrecal` | + +## Primera vez + +En el servidor, en la carpeta de la web (la que ya es un clon del repo): + + git fetch && git checkout pedir-evento + sudo ./despliegue/instalar.sh + +Después, dos cosas a mano: + +1. Añadir esta línea dentro del `server{}` de www.laenre.net y recargar: + + include /etc/nginx/snippets/enrecal.conf; + + sudo nginx -t && sudo systemctl reload nginx + + El fragmento también deja de servir `.git`, `gestor/`, `despliegue/`, + `README.md`, `context.txt` e `info.txt`, que ahora mismo se pueden + descargar desde la web. + +2. Crear las cuentas. `cuenta.sh` para el servicio, crea la cuenta y lo + vuelve a arrancar: + + sudo ./despliegue/cuenta.sh -usuario asamblea -nombre "Asamblea de La Enre" -rol enre -clave 'LA-CLAVE' -definitiva cuenta + sudo ./despliegue/cuenta.sh -usuario externas -nombre "Asamblea de externas" -rol externas cuenta + sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta + + Sin `-clave`, se genera una al azar y se imprime: se entrega en mano y se + cambia al entrar. Con `-clave X` sin `-definitiva`, también se pide + cambiarla al entrar. Ninguna clave va en el repositorio. + +Comprobar: `https://www.laenre.net/pedir/` carga y `https://www.laenre.net/.git/HEAD` +da 403. + +## Actualizar + + git pull + sudo ./despliegue/instalar.sh + +No toca la configuración ni los datos. + +## Si el servidor no tiene Go + +`instalar.sh` compila si encuentra `go`. Si no, se compila en otra máquina y se +copia el binario a `despliegue/enrecal` antes de lanzar el instalador: + + cd gestor && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -o ../despliegue/enrecal . + scp ../despliegue/enrecal servidor:/ruta/de/la/web/despliegue/ + +## Día a día + +- Otra clave para una cuenta: `sudo ./despliegue/cuenta.sh -usuario X clave` +- Registro del servicio: `journalctl -u enrecal` +- Copia de los datos: basta con copiar `/var/lib/enrecal/registro.jsonl`, + también con el servicio en marcha, porque solo se escribe al final. +- La configuración (cupo, ventana, qué finde es tranqui o con ruido) se cambia + en `/etc/enrecal/config.json` y se aplica con `sudo systemctl restart enrecal`. diff --git a/despliegue/config.ejemplo.json b/despliegue/config.ejemplo.json new file mode 100644 index 0000000..1157426 --- /dev/null +++ b/despliegue/config.ejemplo.json @@ -0,0 +1,19 @@ +{ + "escucha": "127.0.0.1:8090", + "base_url": "https://www.laenre.net", + "dir_datos": "/var/lib/enrecal", + "dir_plantillas": "/opt/enrecal/plantillas", + "log_auth": "/var/log/enrecal/auth.log", + "cookie_segura": true, + "local": false, + "meses_vista": 4, + "reglas": { + "meses_ventana": 3, + "cupo_dias": 3, + "dias_minimo_confirmar": 7, + "dias_maximo_pendiente": 30, + "dias_reservables": [5, 6, 7], + "ancla_finde": "2026-09-25", + "turno_ancla": "ruido" + } +} diff --git a/despliegue/cuenta.sh b/despliegue/cuenta.sh new file mode 100755 index 0000000..b54ee06 --- /dev/null +++ b/despliegue/cuenta.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Da de alta una cuenta o le cambia la clave, con el servicio parado. +# +# El gestor lee el registro al arrancar, así que una cuenta creada con el +# servicio en marcha no se vería hasta reiniciarlo. Este script lo para, hace +# la operación como el usuario enrecal (para que el registro no cambie de dueño) +# y lo vuelve a arrancar. +# +# sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta +# sudo ./despliegue/cuenta.sh -usuario hacklab clave +# +# Opciones: -rol enre|interno|externas, -clave X (si no, se genera una al +# azar), -definitiva (la clave no se pide cambiar al entrar). +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Hay que lanzarlo con sudo." >&2 + exit 1 +fi + +systemctl stop enrecal +trap 'systemctl start enrecal' EXIT +runuser -u enrecal -- /opt/enrecal/enrecal -config /etc/enrecal/config.json "$@" diff --git a/despliegue/enrecal.service b/despliegue/enrecal.service new file mode 100644 index 0000000..4ce5995 --- /dev/null +++ b/despliegue/enrecal.service @@ -0,0 +1,44 @@ +[Unit] +Description=Gestor de reservas de La Enredadera (/pedir/) +After=network.target + +[Service] +Type=simple +User=enrecal +Group=enrecal +ExecStart=/opt/enrecal/enrecal -config /etc/enrecal/config.json servir +Restart=on-failure +RestartSec=5 + +# Solo puede escribir en sus datos y en su registro de accesos. +ProtectSystem=strict +ReadWritePaths=/var/lib/enrecal /var/log/enrecal +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +NoNewPrivileges=true +CapabilityBoundingSet= +AmbientCapabilities= +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged +UMask=0027 + +# Tope de memoria: el gestor limita argon2 a dos cálculos a la vez (unos +# 130 MB), así que 512 MB sobran y protegen al resto de servicios de la máquina. +MemoryMax=512M + +[Install] +WantedBy=multi-user.target diff --git a/despliegue/instalar.sh b/despliegue/instalar.sh new file mode 100755 index 0000000..506017a --- /dev/null +++ b/despliegue/instalar.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Instala o actualiza el gestor de /pedir/ en el servidor. +# +# sudo ./despliegue/instalar.sh +# +# Se lanza desde la raíz del repositorio. Se puede repetir: la segunda vez solo +# actualiza el programa y las plantillas, y no toca ni la configuración ni los +# datos. +set -eu + +RAIZ=$(cd "$(dirname "$0")/.." && pwd) +PROG=/opt/enrecal +DATOS=/var/lib/enrecal +LOGS=/var/log/enrecal +CONF=/etc/enrecal + +if [ "$(id -u)" -ne 0 ]; then + echo "Hay que lanzarlo con sudo." >&2 + exit 1 +fi + +# 1. El programa: se compila aquí si hay Go, o se usa el que se haya copiado a +# despliegue/enrecal (ver README.md). +if command -v go >/dev/null 2>&1; then + echo "Compilando..." + (cd "$RAIZ/gestor" && CGO_ENABLED=0 go build -trimpath -buildvcs=false -o "$RAIZ/despliegue/enrecal" .) +elif [ ! -x "$RAIZ/despliegue/enrecal" ]; then + echo "No hay Go en esta máquina ni un despliegue/enrecal ya compilado." >&2 + echo "Compílalo en otra máquina y cópialo (ver despliegue/README.md)." >&2 + exit 1 +fi + +# 2. Usuario propio, sin shell ni carpeta personal. +if ! id enrecal >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin enrecal +fi + +# 3. Carpetas. El programa y las plantillas fuera de la web pública. +install -d -m 0755 "$PROG" "$PROG/plantillas" +install -d -m 0750 -o enrecal -g enrecal "$DATOS" "$LOGS" +install -d -m 0755 "$CONF" + +install -m 0755 "$RAIZ/despliegue/enrecal" "$PROG/enrecal" +rm -f "$PROG"/plantillas/*.tmpl.html +install -m 0644 "$RAIZ"/gestor/plantillas/*.tmpl.html "$PROG/plantillas/" + +if [ ! -f "$CONF/config.json" ]; then + install -m 0644 "$RAIZ/despliegue/config.ejemplo.json" "$CONF/config.json" + echo "Configuración nueva en $CONF/config.json" +fi + +# 4. nginx: el límite de intentos va en http{} (conf.d) y el resto en un +# fragmento que se incluye con una línea dentro del server{} de la web. +if [ -d /etc/nginx ]; then + install -d -m 0755 /etc/nginx/conf.d /etc/nginx/snippets + install -m 0644 "$RAIZ/despliegue/nginx-zona.conf" /etc/nginx/conf.d/enrecal-zona.conf + install -m 0644 "$RAIZ/despliegue/nginx-laenre.conf" /etc/nginx/snippets/enrecal.conf +fi + +# 5. systemd. +install -m 0644 "$RAIZ/despliegue/enrecal.service" /etc/systemd/system/enrecal.service +systemctl daemon-reload +systemctl enable enrecal >/dev/null 2>&1 +systemctl restart enrecal +sleep 1 +systemctl --no-pager --lines=3 status enrecal || true + +cat <<'FIN' + +Listo. Si es la primera vez, faltan dos cosas: + + 1. En el server{} de www.laenre.net, añadir esta línea y recargar nginx: + include /etc/nginx/snippets/enrecal.conf; + sudo nginx -t && sudo systemctl reload nginx + + 2. Crear las cuentas con despliegue/cuenta.sh (ver despliegue/README.md). +FIN diff --git a/despliegue/nginx-laenre.conf b/despliegue/nginx-laenre.conf new file mode 100644 index 0000000..e3f05b3 --- /dev/null +++ b/despliegue/nginx-laenre.conf @@ -0,0 +1,28 @@ +# Se incluye dentro del server{} de www.laenre.net: +# include /etc/nginx/snippets/enrecal.conf; + +# La web se despliega con git, así que en la carpeta pública están también +# .git, el código del gestor y las notas internas. Nada de eso se sirve. +location ~ /\.(?!well-known) { deny all; } +location ~ ^/(gestor|despliegue)/ { deny all; } +location ~ ^/(README\.md|context\.txt|info\.txt)$ { deny all; } + +location /pedir/ { + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + # Imprescindible pisar las dos: si no, cualquiera manda su propia + # X-Real-IP y se salta el bloqueo por IP del gestor. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + client_max_body_size 16k; +} + +location = /pedir/entrar { + limit_req zone=enrecal burst=5 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + client_max_body_size 16k; +} diff --git a/despliegue/nginx-zona.conf b/despliegue/nginx-zona.conf new file mode 100644 index 0000000..2809644 --- /dev/null +++ b/despliegue/nginx-zona.conf @@ -0,0 +1,3 @@ +# Va dentro de http{}: en Debian, /etc/nginx/conf.d/ ya se incluye ahí. +# Diez intentos de acceso por minuto y por IP en /pedir/entrar. +limit_req_zone $binary_remote_addr zone=enrecal:1m rate=10r/m; diff --git a/gestor/auth.go b/gestor/auth.go index 51044cd..8e4b5ed 100644 --- a/gestor/auth.go +++ b/gestor/auth.go @@ -26,6 +26,19 @@ const ( argonSal = 16 ) +// argonHuecos limita cuántos cálculos de argon2 corren a la vez. Cada uno +// reserva argonMemoria (64 MiB): sin este tope, sesenta intentos de acceso +// simultáneos llevaban el proceso a casi 4 GB y podían tumbar la máquina, que +// comparte con gitea y el resto de servicios de la Enre. Los que llegan de más +// esperan su turno en vez de reservar memoria. +var argonHuecos = make(chan struct{}, 2) + +func argonID(clave, sal []byte, t, m uint32, p uint8, largo uint32) []byte { + argonHuecos <- struct{}{} + defer func() { <-argonHuecos }() + return argon2.IDKey(clave, sal, t, m, p, largo) +} + func aleatorio(n int) []byte { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -40,7 +53,7 @@ func nuevoID() string { return hex.EncodeToString(aleatorio(16)) } // lleva dentro los parametros con los que se genero. func HashClave(clave string) string { sal := aleatorio(argonSal) - h := argon2.IDKey([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo) + h := argonID([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemoria, argonIteraciones, argonHilos, base64.RawStdEncoding.EncodeToString(sal), @@ -72,7 +85,13 @@ func VerificarClave(codificado, clave string) bool { if err != nil { return false } - calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria), + // Los parámetros salen del propio hash, que escribimos nosotros; aun así se + // acotan, para que un registro manipulado no pida gigas de memoria. + if memoria <= 0 || memoria > argonMemoria || iteraciones <= 0 || iteraciones > 10 || + hilos <= 0 || hilos > 16 || len(esperado) == 0 || len(esperado) > 64 { + return false + } + calculado := argonID([]byte(clave), sal, uint32(iteraciones), uint32(memoria), uint8(hilos), uint32(len(esperado))) return subtle.ConstantTimeCompare(calculado, esperado) == 1 } @@ -187,6 +206,27 @@ func (s *Sesiones) TomarClave(id string) (string, string) { return usuario, clave } +func (s *Sesiones) PonerAviso(id, aviso, fallo string) { + s.mu.Lock() + defer s.mu.Unlock() + if ses, ok := s.m[id]; ok { + ses.Aviso, ses.Error = aviso, fallo + } +} + +// TomarAviso devuelve el aviso pendiente y lo borra: se enseña una vez. +func (s *Sesiones) TomarAviso(id string) (string, string) { + s.mu.Lock() + defer s.mu.Unlock() + ses, ok := s.m[id] + if !ok { + return "", "" + } + aviso, fallo := ses.Aviso, ses.Error + ses.Aviso, ses.Error = "", "" + return aviso, fallo +} + func (s *Sesiones) Borrar(id string) { s.mu.Lock() defer s.mu.Unlock() @@ -234,29 +274,22 @@ func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo { } } -func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) { +// Intentar comprueba y apunta el intento en la misma operación, antes de +// calcular la clave. Si se apuntara después, como hacía Fallo, cien intentos +// simultáneos pasaban todos la comprobación antes de que se apuntara el +// primero. +func (c *Cerrojo) Intentar(clave string) (bool, time.Duration) { c.mu.Lock() defer c.mu.Unlock() ahora := time.Now() - for _, k := range claves { - recientes := c.podar(k, ahora) - if len(recientes) >= c.maxIntentos { - ultimo := recientes[len(recientes)-1] - if falta := c.espera - ahora.Sub(ultimo); falta > 0 { - return true, falta - } + recientes := c.podar(clave, ahora) + if len(recientes) >= c.maxIntentos { + if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 { + return false, falta } } - return false, 0 -} - -func (c *Cerrojo) Fallo(claves ...string) { - c.mu.Lock() - defer c.mu.Unlock() - ahora := time.Now() - for _, k := range claves { - c.fallos[k] = append(c.podar(k, ahora), ahora) - } + c.fallos[clave] = append(recientes, ahora) + return true, 0 } func (c *Cerrojo) Limpiar(claves ...string) { diff --git a/gestor/config.go b/gestor/config.go index e29c25b..977c339 100644 --- a/gestor/config.go +++ b/gestor/config.go @@ -32,7 +32,7 @@ func ConfigPorDefecto() Config { Escucha: "127.0.0.1:8090", BaseURL: "https://www.laenre.net", DirDatos: "/var/lib/enrecal", - DirPlantillas: "/var/www/laenre/gestor/plantillas", + DirPlantillas: "/opt/enrecal/plantillas", DirEstatico: "/var/www/laenre", LogAuth: "/var/log/enrecal/auth.log", CookieSegura: true, @@ -89,6 +89,11 @@ func (c Config) Validar() error { const ( inactividadSesion = 8 * time.Hour maxIntentos = 5 + // Por cuenta el tope es más alto que por IP: si fuera 5, cualquiera podría + // dejar a la asamblea sin entrar fallando 5 veces cada cuarto de hora. Con + // argon2 y claves de 12 caracteres, 20 intentos cada 10 minutos no bastan + // para adivinar nada. + maxIntentosCuenta = 20 ventanaIntentos = 10 * time.Minute esperaBloqueo = 15 * time.Minute ) diff --git a/gestor/main.go b/gestor/main.go index 7948ec5..b85e8d0 100644 --- a/gestor/main.go +++ b/gestor/main.go @@ -25,7 +25,8 @@ type App struct { cfg Config alm Almacen ses *Sesiones - cer *Cerrojo + cer *Cerrojo // por IP + cerU *Cerrojo // por cuenta auth *AuthLog // mu serializa las escrituras de eventos: comprobar las reglas y guardar @@ -51,7 +52,7 @@ func main() { local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx") usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave") nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta") - rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre o interno") + rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas") claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar") definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)") ) @@ -156,6 +157,7 @@ func NuevaApp(cfg Config) (*App, error) { alm: reg, ses: NuevasSesiones(inactividadSesion), cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo), + cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo), auth: al, plantillas: plantillas, }, nil @@ -177,8 +179,17 @@ func (a *App) nombres() map[string]string { return out } +func (a *App) roles() map[string]Rol { + out := map[string]Rol{} + for _, c := range a.alm.Cuentas() { + out[c.Usuario] = c.Rol + } + return out +} + func (a *App) vista() Vista { return Vista{ + roles: a.roles(), reglas: a.cfg.Reglas, exc: a.alm.Excepciones(), eventos: a.alm.Eventos(), @@ -224,8 +235,8 @@ func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiv if nombre == "" { return errors.New("falta -nombre: el nombre visible del colectivo") } - if rol != RolEnre && rol != RolInterno { - return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno) + if !rol.Valido() { + return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas) } if _, existe := a.alm.BuscarCuenta(usuario); existe { return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario) diff --git a/gestor/modelo.go b/gestor/modelo.go index ee40d7b..57a4f93 100644 --- a/gestor/modelo.go +++ b/gestor/modelo.go @@ -67,9 +67,15 @@ type Rol string const ( RolEnre Rol = "enre" // la asamblea: confirma y rechaza - RolInterno Rol = "interno" // colectivo de la casa: solicita y amadrina + RolInterno Rol = "interno" // colectivo de la Enre: solicita y amadrina + // RolExternas es la cuenta de la asamblea de externas: apunta los eventos + // de colectivos de fuera que se hablan allí, sin cupo, y la asamblea de la + // Enre los confirma. + RolExternas Rol = "externas" ) +func (r Rol) Valido() bool { return r == RolEnre || r == RolInterno || r == RolExternas } + type Cuenta struct { Usuario string `json:"usuario"` Nombre string `json:"nombre"` // nombre visible del colectivo @@ -168,4 +174,10 @@ type Sesion struct { // por la URL y acabe en el historial o en el log de nginx. ClaveDe string ClaveNueva string + + // Aviso o error de la siguiente página. Van por la sesión y no por la URL: + // con «?a=» cualquiera podía fabricar un enlace de laenre.net que + // enseñara el texto que quisiera. + Aviso string + Error string } diff --git a/gestor/pedir.go b/gestor/pedir.go index 28555b9..8d6a400 100644 --- a/gestor/pedir.go +++ b/gestor/pedir.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/url" + "path" "sort" "strings" "time" @@ -134,19 +135,20 @@ func ipCliente(r *http.Request) string { // Base es lo que necesita el armazón de la página: el título, quién ha entrado // y los avisos. type Base struct { - Titulo string - Sub string - Actual string // qué entrada del menú de la sección va marcada - Dentro bool - Usuario string - Nombre string - EsEnre bool - CSRF string - Aviso string - Error string - Reglas Reglas - Hasta string - Sig string // adónde volver después de entrar, ya comprobado + Titulo string + Sub string + Actual string // qué entrada del menú de la sección va marcada + Dentro bool + Usuario string + Nombre string + EsEnre bool + EsExternas bool + CSRF string + Aviso string + Error string + Reglas Reglas + Hasta string + Sig string // adónde volver después de entrar, ya comprobado } // destinoSeguro deja pasar solo rutas de la propia sección: nada de otro @@ -159,6 +161,14 @@ func destinoSeguro(sig string) string { strings.HasPrefix(sig, "//") { return "" } + // Nada de «/pedir/../»: la ruta tiene que estar ya limpia. + limpia := path.Clean(u.Path) + if strings.HasSuffix(u.Path, "/") && limpia != "/" { + limpia += "/" + } + if limpia != u.Path || !strings.HasPrefix(limpia, "/pedir/") { + return "" + } return u.RequestURI() } @@ -181,6 +191,7 @@ func (a *App) base(titulo, sub, actual string, c Cuenta, ses *Sesion) Base { b.Usuario = c.Usuario b.Nombre = c.Nombre b.EsEnre = c.Rol == RolEnre + b.EsExternas = c.Rol == RolExternas b.CSRF = ses.CSRF } return b @@ -275,6 +286,12 @@ func (a *App) csrfValido(ses *Sesion, r *http.Request) bool { return iguales(r.PostFormValue("csrf"), ses.CSRF) } +// avisar deja un aviso para la página siguiente y redirige a ella. +func (a *App) avisar(w http.ResponseWriter, r *http.Request, ses *Sesion, ruta, aviso string) { + a.ses.PonerAviso(ses.ID, aviso, "") + http.Redirect(w, r, ruta, http.StatusSeeOther) +} + // ------------------------------------------------------------------ portada func (a *App) verPortada(w http.ResponseWriter, r *http.Request) { @@ -302,9 +319,9 @@ func (a *App) verPortada(w http.ResponseWriter, r *http.Request) { return } datos.Sig = sig - datos.Aviso = r.URL.Query().Get("a") if sig != "" { - datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo." + datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo. " + + "Colectivos externos: se pide en la asamblea de externas, los lunes de 19:00 a 20:00." } a.pintar(w, "portada.tmpl.html", datos) } @@ -325,9 +342,19 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) { usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario"))) clave := r.PostFormValue("clave") ip := ipCliente(r) - cerrojos := []string{"ip:" + ip, "usuario:" + usuario} + // Un usuario con forma imposible cae siempre en el mismo cajón: si no, + // cada nombre inventado (hasta 64 KB) sería una entrada más en memoria. + llaveU := usuario + if ValidarUsuario(usuario) != nil { + llaveU = "?" + } - if bloqueado, falta := a.cer.Bloqueado(cerrojos...); bloqueado { + // El intento se apunta ANTES de calcular la clave: ver Cerrojo.Intentar. + pasa, falta := a.cer.Intentar(ip) + if pasa { + pasa, falta = a.cerU.Intentar(llaveU) + } + if !pasa { a.auth.Escribir("AUTH_BLOCK", ip, usuario, "demasiados_intentos") a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.", int(falta.Minutes())+1)) @@ -339,19 +366,18 @@ func (a *App) entrar(w http.ResponseWriter, r *http.Request) { // Se gasta el mismo tiempo que con una cuenta real para que no se pueda // averiguar que usuarios existen. VerificarClave(hashInutil, clave) - a.cer.Fallo(cerrojos...) a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida") a.errorAcceso(w, r, "Usuario o contraseña incorrectos.") return } if !VerificarClave(c.Hash, clave) { - a.cer.Fallo(cerrojos...) a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta") a.errorAcceso(w, r, "Usuario o contraseña incorrectos.") return } - a.cer.Limpiar(cerrojos...) + a.cer.Limpiar(ip) + a.cerU.Limpiar(llaveU) a.auth.Escribir("AUTH_OK", ip, usuario, "-") ses := a.ses.Crear(c.Usuario) a.ponerCookie(w, ses.ID, 0) @@ -415,6 +441,7 @@ type FilaMia struct { Etiqueta string Caduca string Liberable bool + Externas bool // la pidió la asamblea de externas: va en naranja } func (a *App) filaMia(e Evento) FilaMia { @@ -422,6 +449,10 @@ func (a *App) filaMia(e Evento) FilaMia { Evento: e, Dias: e.NumDias(), Legible: rangoLegible(e), Etiqueta: e.Estado.Etiqueta(), Liberable: e.Estado.Ocupa(), } + if c, ok := a.alm.BuscarCuenta(e.Solicitante); ok && c.Rol == RolExternas { + f.Externas = true + f.Etiqueta = etiquetaEstado(e.Estado, true) + } if e.Estado == Solicitado { limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar) porTiempo := DiaDe(e.Creado.AddDate(0, 0, a.cfg.Reglas.DiasMaximoPendiente)) @@ -459,7 +490,7 @@ func (a *App) verMisFechas(w http.ResponseWriter, r *http.Request, c Cuenta, ses Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)), DiasUsados: listaDias(usados), } - datos.Aviso = r.URL.Query().Get("a") + datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID) a.pintar(w, "mis-fechas.tmpl.html", datos) } @@ -537,7 +568,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses * if err == nil { estado := Solicitado if c.Rol == RolEnre { - estado = Confirmado // la asamblea de la casa no se pide permiso a si misma + estado = Confirmado // la asamblea de la Enre no se pide permiso a si misma } e = Evento{ ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario, @@ -558,8 +589,7 @@ func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses * return } log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado) - http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Fecha apuntada: "+rangoLegible(e)), - http.StatusSeeOther) + a.avisar(w, r, ses, "/pedir/mis-fechas", "Fecha apuntada: "+rangoLegible(e)) } func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) { @@ -592,8 +622,7 @@ func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre { destino = "/pedir/todas" } - http.Redirect(w, r, destino+"?a="+url.QueryEscape("Fecha liberada: "+rangoLegible(e)), - http.StatusSeeOther) + a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e)) } // ------------------------------------------------------------------- todas @@ -619,10 +648,14 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se if quien == "" { quien = e.Solicitante } - if e.Externo() { + fila := a.filaMia(e) + switch { + case fila.Externas: + quien = e.Amadrinado + " · asamblea de externas" + case e.Externo(): quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien) } - filas = append(filas, FilaTodas{FilaMia: a.filaMia(e), Quien: quien}) + filas = append(filas, FilaTodas{FilaMia: fila, Quien: quien}) } sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde }) @@ -633,7 +666,7 @@ func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Se Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses), Filas: filas, } - datos.Aviso = r.URL.Query().Get("a") + datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID) a.pintar(w, "todas.tmpl.html", datos) } @@ -710,8 +743,7 @@ func (a *App) hacerEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error())) return } - http.Redirect(w, r, "/pedir/todas?a="+url.QueryEscape("Evento modificado: "+nuevo.Titulo), - http.StatusSeeOther) + a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo) } // ---------------------------------------------------------------- pendientes @@ -759,7 +791,7 @@ func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, se Base Filas []FilaPendiente }{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas} - datos.Aviso = r.URL.Query().Get("a") + datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID) a.pintar(w, "pendientes.tmpl.html", datos) } @@ -796,9 +828,8 @@ func (a *App) hacerResolver(w http.ResponseWriter, r *http.Request, c Cuenta, se return } log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante) - http.Redirect(w, r, "/pedir/pendientes?a="+url.QueryEscape( - fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))), - http.StatusSeeOther) + a.avisar(w, r, ses, "/pedir/pendientes", + fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))) } // -------------------------------------------------------------------- findes @@ -832,7 +863,7 @@ func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *S Base Filas []FilaFinde }{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas} - datos.Aviso = r.URL.Query().Get("a") + datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID) a.pintar(w, "findes.tmpl.html", datos) } @@ -857,7 +888,7 @@ func (a *App) hacerFinde(w http.ResponseWriter, r *http.Request, c Cuenta, ses * if turno == "" { aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible()) } - http.Redirect(w, r, "/pedir/findes?a="+url.QueryEscape(aviso), http.StatusSeeOther) + a.avisar(w, r, ses, "/pedir/findes", aviso) } // ------------------------------------------------------------------- cuentas @@ -873,8 +904,7 @@ func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses * Cuentas: a.alm.Cuentas(), } datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID) - datos.Aviso = r.URL.Query().Get("a") - datos.Error = r.URL.Query().Get("e") + datos.Aviso, datos.Error = a.ses.TomarAviso(ses.ID) a.pintar(w, "cuentas.tmpl.html", datos) } @@ -882,8 +912,11 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses accion := r.PostFormValue("accion") usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario"))) - volver := func(params string) { http.Redirect(w, r, "/pedir/cuentas"+params, http.StatusSeeOther) } - problema := func(err error) { volver("?e=" + url.QueryEscape(primeraMayuscula(err.Error()))) } + volver := func(aviso string) { a.avisar(w, r, ses, "/pedir/cuentas", aviso) } + problema := func(err error) { + a.ses.PonerAviso(ses.ID, "", primeraMayuscula(err.Error())) + http.Redirect(w, r, "/pedir/cuentas", http.StatusSeeOther) + } switch accion { case "crear": @@ -897,7 +930,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses problema(fmt.Errorf("falta el nombre del colectivo")) return } - if rol != RolEnre && rol != RolInterno { + if !rol.Valido() { problema(fmt.Errorf("rol desconocido")) return } @@ -952,7 +985,7 @@ func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses if otra.Activa { hecho = "activada" } - volver("?a=" + url.QueryEscape(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho))) + volver(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho)) default: a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.") @@ -1018,8 +1051,7 @@ func (a *App) hacerClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses * http.Redirect(w, r, sig, http.StatusSeeOther) return } - http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Contraseña cambiada"), - http.StatusSeeOther) + a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada") } // -------------------------------------------------------------------- varios diff --git a/gestor/pedir_test.go b/gestor/pedir_test.go index f1dc11a..d38be4d 100644 --- a/gestor/pedir_test.go +++ b/gestor/pedir_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "testing" + "time" +) func TestDestinoSeguro(t *testing.T) { casos := map[string]string{ @@ -21,3 +24,45 @@ func TestDestinoSeguro(t *testing.T) { } } } + +func TestDestinoSeguroSinPuntos(t *testing.T) { + for _, sig := range []string{"/pedir/../index.html", "/pedir/./nueva", "/pedir//nueva", "/pedir/x/../../evil"} { + if got := destinoSeguro(sig); got != "" { + t.Errorf("destinoSeguro(%q) = %q, quiero vacío", sig, got) + } + } + if got := destinoSeguro("/pedir/"); got != "/pedir/" { + t.Errorf("destinoSeguro(/pedir/) = %q", got) + } +} + +func TestCerrojoApuntaAntesDeCalcular(t *testing.T) { + c := NuevoCerrojo(5, time.Minute, time.Minute) + pasan := 0 + for i := 0; i < 50; i++ { + if ok, _ := c.Intentar("ip:1.2.3.4"); ok { + pasan++ + } + } + if pasan != 5 { + t.Fatalf("pasaron %d intentos, quiero 5", pasan) + } +} + +func TestArgonParametrosAcotados(t *testing.T) { + // Un hash manipulado que pide 4 GiB de memoria no se calcula. + malo := "$argon2id$v=19$m=4194304,t=3,p=4$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g" + if VerificarClave(malo, "loquesea") { + t.Fatal("no deberia verificar") + } +} + +func TestConfigEjemploValida(t *testing.T) { + c, err := CargarConfig("../despliegue/config.ejemplo.json") + if err != nil { + t.Fatalf("despliegue/config.ejemplo.json no carga: %v", err) + } + if !c.CookieSegura || c.Local { + t.Fatal("el ejemplo de producción tiene que llevar cookie_segura y no local") + } +} diff --git a/gestor/plantillas/_mes.tmpl.html b/gestor/plantillas/_mes.tmpl.html index b4eedf9..112309d 100644 --- a/gestor/plantillas/_mes.tmpl.html +++ b/gestor/plantillas/_mes.tmpl.html @@ -25,7 +25,7 @@ {{- if .Eventos}}
- Los colectivos de fuera no tienen cuenta: sus eventos los pide un colectivo de la casa - que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder. + Los colectivos de fuera no tienen cuenta: sus eventos los apunta la cuenta de externas + en la asamblea de externas, o un colectivo de la Enre que los amadrina. Cuantas menos + contraseñas haya guardadas, menos hay que perder.
+ Aquí se apuntan los eventos de colectivos de fuera que se hablan en la asamblea de + externas. No hay cupo. Quedan en naranja hasta que la asamblea de la Enre los confirme. + Se pueden pedir fechas hasta el {{.Hasta}}. +
+ {{- else}}Os quedan {{.Restantes}} de {{.Reglas.CupoDias}} días dentro de los próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}. {{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
+ {{- end}}