ENRE_WEB/gestor/auth.go
SITO ce3127baff Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
2026-09-23 11:32:25 +02:00

355 lines
8.7 KiB
Go

package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"sync"
"time"
"unicode"
"golang.org/x/crypto/argon2"
)
// Parametros de argon2id. 64 MiB por intento es suficiente para que probar
// claves a lo bruto salga caro y poco para el servidor, que atiende a treinta
// cuentas.
const (
argonMemoria = 64 * 1024
argonIteraciones = 3
argonHilos = 4
argonLargo = 32
argonSal = 16
)
func aleatorio(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("sin fuente de aleatoriedad: " + err.Error())
}
return b
}
func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
// HashClave devuelve la clave cifrada en el formato codificado de argon2, que
// 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)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
base64.RawStdEncoding.EncodeToString(sal),
base64.RawStdEncoding.EncodeToString(h))
}
// hashInutil se usa para gastar el mismo tiempo cuando la cuenta no existe, de
// forma que no se pueda averiguar que usuarios hay midiendo la respuesta.
var hashInutil = HashClave("cuenta que no existe")
func VerificarClave(codificado, clave string) bool {
partes := strings.Split(codificado, "$")
if len(partes) != 6 || partes[1] != "argon2id" {
return false
}
var version, memoria, iteraciones int
var hilos int
if _, err := fmt.Sscanf(partes[2], "v=%d", &version); err != nil {
return false
}
if _, err := fmt.Sscanf(partes[3], "m=%d,t=%d,p=%d", &memoria, &iteraciones, &hilos); err != nil {
return false
}
sal, err := base64.RawStdEncoding.DecodeString(partes[4])
if err != nil {
return false
}
esperado, err := base64.RawStdEncoding.DecodeString(partes[5])
if err != nil {
return false
}
calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
uint8(hilos), uint32(len(esperado)))
return subtle.ConstantTimeCompare(calculado, esperado) == 1
}
// ValidarClave es la politica de contrasenas: larga antes que retorcida, que es
// lo que de verdad aguanta un ataque por diccionario.
func ValidarClave(c string) error {
if len([]rune(c)) < 12 {
return fmt.Errorf("la contraseña tiene que tener al menos 12 caracteres")
}
if len(c) > 256 {
return fmt.Errorf("la contraseña no puede pasar de 256 caracteres")
}
distintos := map[rune]bool{}
for _, r := range c {
distintos[r] = true
}
if len(distintos) < 5 {
return fmt.Errorf("la contraseña repite demasiado los mismos caracteres")
}
return nil
}
// ClaveTemporal genera la clave de un solo uso que se entrega en mano al dar de
// alta un colectivo. Sin caracteres que se confundan al copiarla a papel.
func ClaveTemporal() string {
const alfabeto = "abcdefghjkmnpqrstuvwxyz23456789"
b := aleatorio(20)
var sb strings.Builder
for i, v := range b {
if i > 0 && i%5 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(alfabeto[int(v)%len(alfabeto)])
}
return sb.String()
}
func ValidarUsuario(u string) error {
if len(u) < 3 || len(u) > 32 {
return fmt.Errorf("el usuario tiene entre 3 y 32 caracteres")
}
for _, r := range u {
if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
return fmt.Errorf("el usuario solo admite minúsculas, números, guion y guion bajo")
}
}
return nil
}
// Sesiones viven solo en memoria: se pueden revocar al instante y un reinicio
// del servicio cierra todas las sesiones abiertas, que es lo que interesa.
type Sesiones struct {
mu sync.Mutex
m map[string]*Sesion
maxInactivo time.Duration
}
func NuevasSesiones(maxInactivo time.Duration) *Sesiones {
return &Sesiones{m: map[string]*Sesion{}, maxInactivo: maxInactivo}
}
func (s *Sesiones) Crear(usuario string) *Sesion {
s.mu.Lock()
defer s.mu.Unlock()
ses := &Sesion{
ID: hex.EncodeToString(aleatorio(32)),
Usuario: usuario,
CSRF: hex.EncodeToString(aleatorio(32)),
Creada: time.Now(),
Vista: time.Now(),
}
s.m[ses.ID] = ses
return ses
}
func (s *Sesiones) Buscar(id string) (*Sesion, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return nil, false
}
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
return nil, false
}
ses.Vista = time.Now()
return ses, true
}
// PonerClave guarda la clave temporal recien generada para ensenarla una sola
// vez en la siguiente pagina, sin que pase por la URL.
func (s *Sesiones) PonerClave(id, usuario, clave string) {
s.mu.Lock()
defer s.mu.Unlock()
if ses, ok := s.m[id]; ok {
ses.ClaveDe, ses.ClaveNueva = usuario, clave
}
}
// TomarClave la devuelve y la borra: no se vuelve a mostrar.
func (s *Sesiones) TomarClave(id string) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return "", ""
}
usuario, clave := ses.ClaveDe, ses.ClaveNueva
ses.ClaveDe, ses.ClaveNueva = "", ""
return usuario, clave
}
func (s *Sesiones) Borrar(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
}
// BorrarDeUsuario cierra todas las sesiones de una cuenta: al cambiarle la
// clave o al desactivarla.
func (s *Sesiones) BorrarDeUsuario(usuario string) {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if ses.Usuario == usuario {
delete(s.m, id)
}
}
}
func (s *Sesiones) Limpiar() {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
}
}
}
// Cerrojo cuenta intentos fallidos por IP y por cuenta. Es la primera barrera,
// dentro de la propia aplicacion; fail2ban es la segunda, en el cortafuegos.
type Cerrojo struct {
mu sync.Mutex
fallos map[string][]time.Time
maxIntentos int
ventana time.Duration
espera time.Duration
}
func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
return &Cerrojo{
fallos: map[string][]time.Time{},
maxIntentos: maxIntentos,
ventana: ventana,
espera: espera,
}
}
func (c *Cerrojo) Bloqueado(claves ...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
}
}
}
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)
}
}
func (c *Cerrojo) Limpiar(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
for _, k := range claves {
delete(c.fallos, k)
}
}
func (c *Cerrojo) podar(k string, ahora time.Time) []time.Time {
var out []time.Time
for _, t := range c.fallos[k] {
if ahora.Sub(t) <= c.ventana {
out = append(out, t)
}
}
if len(out) == 0 {
delete(c.fallos, k)
} else {
c.fallos[k] = out
}
return out
}
// AuthLog escribe el registro que lee fail2ban. Formato fijo y de una sola
// linea por intento.
type AuthLog struct {
mu sync.Mutex
f *os.File
}
func AbrirAuthLog(ruta string) (*AuthLog, error) {
if ruta == "" {
return &AuthLog{}, nil
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
return &AuthLog{f: f}, nil
}
// limpiarCampo evita que un usuario inventado meta saltos de linea o espacios
// en el log: si no, se podria falsificar una linea AUTH_FAIL con la IP de otro
// y hacer que fail2ban banee a quien no toca.
func limpiarCampo(s string) string {
if s == "" {
return "-"
}
var sb strings.Builder
for _, r := range s {
if r > unicode.MaxASCII || unicode.IsSpace(r) || !unicode.IsPrint(r) {
sb.WriteByte('.')
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
sb.WriteRune(r)
} else {
sb.WriteByte('.')
}
if sb.Len() >= 32 {
break
}
}
if sb.Len() == 0 {
return "-"
}
return sb.String()
}
func (l *AuthLog) Escribir(evento, ip, usuario, motivo string) {
linea := fmt.Sprintf("%s %s ip=%s usuario=%s motivo=%s\n",
time.Now().Format(time.RFC3339), evento,
limpiarCampo(ip), limpiarCampo(usuario), limpiarCampo(motivo))
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.WriteString(linea)
return
}
os.Stderr.WriteString(linea)
}
// iguales compara dos cadenas sin filtrar por el tiempo cuanto coinciden.
func iguales(a, b string) bool {
return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func (l *AuthLog) Cerrar() {
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.Close()
}
}