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() } }