- /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.
355 lines
8.7 KiB
Go
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()
|
|
}
|
|
}
|