ENRE_WEB/gestor/auth.go
SITO 7d5dbeec60 Cuenta de externas, arreglos de seguridad, títulos arriba y despliegue
- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el
  colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta
  que la asamblea de la Enre las confirma.
- /pedir/ explica en una línea que los colectivos externos piden fecha en la
  asamblea de externas, los lunes de 19:00 a 20:00.
- Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta
  antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB);
  bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la
  asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de
  31 días como mucho; parámetros de argon2 acotados.
- Títulos pegados arriba en todas las páginas; la franja de la enredadera
  recupera ese alto para seguir llenando la pantalla.
- despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso
  encerrado, fragmento de nginx que además deja de servir .git y las notas
  internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
2026-09-23 12:15:33 +02:00

388 lines
10 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
)
// 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 {
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 := 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),
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
}
// 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
}
// 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) 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()
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,
}
}
// 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()
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
}
}
c.fallos[clave] = append(recientes, ahora)
return true, 0
}
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()
}
}