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.
This commit is contained in:
parent
c3b51be992
commit
ce3127baff
38 changed files with 4029 additions and 52 deletions
355
gestor/auth.go
Normal file
355
gestor/auth.go
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue