- 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.
388 lines
10 KiB
Go
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()
|
|
}
|
|
}
|