feat: jwt_generate, jwt_validate, password_hash, password_verify

Fase 2 del issue 0010 — auth core:
- jwt_generate/validate: HS256 manual con crypto/hmac + crypto/sha256
- password_hash/verify: wrappers de golang.org/x/crypto/bcrypt (cost 12 default)
- JWT rechaza alg != HS256 para mitigar ataque 'alg=none'
- hmac.Equal para comparacion constant-time de firmas
This commit is contained in:
2026-04-18 17:39:00 +02:00
parent 1aab74467b
commit eff5771b03
14 changed files with 517 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
package infra
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"time"
)
// JWTGenerate codifica un JWT firmado con HMAC-SHA256 (alg: HS256).
// Si claims.IssuedAt viene en cero se setea a time.Now().Unix().
// Retorna el token en formato "header.payload.signature" con los tres segmentos
// codificados en base64url sin padding.
func JWTGenerate(claims JWTClaims, secret string) (string, error) {
if secret == "" {
return "", errors.New("jwt_generate: secret vacio")
}
if claims.IssuedAt == 0 {
claims.IssuedAt = time.Now().Unix()
}
header := map[string]string{"alg": "HS256", "typ": "JWT"}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
payloadJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
enc := base64.RawURLEncoding
headerPart := enc.EncodeToString(headerJSON)
payloadPart := enc.EncodeToString(payloadJSON)
signingInput := headerPart + "." + payloadPart
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
sigPart := enc.EncodeToString(mac.Sum(nil))
return signingInput + "." + sigPart, nil
}
+46
View File
@@ -0,0 +1,46 @@
---
name: jwt_generate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func JWTGenerate(claims JWTClaims, secret string) (string, error)"
description: "Codifica y firma un JWT con HMAC-SHA256 (HS256). Retorna el token en formato header.payload.signature. Setea IssuedAt automaticamente si viene en cero."
tags: [jwt, auth, token, hmac, sign, infra]
uses_functions: []
uses_types: [JWTClaims_go_infra]
returns: []
returns_optional: false
error_type: error_go_core
imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, time]
params:
- name: claims
desc: "claims del JWT (sub, iss, aud, exp, iat, custom). Si IssuedAt es 0 se rellena con time.Now()"
- name: secret
desc: "clave HMAC para firmar. No debe estar vacia. Obtenerla de env var o pass_get, nunca hardcoded"
output: "token JWT firmado en formato base64url header.payload.signature"
tested: true
tests: ["genera token valido con claims completas", "setea IssuedAt si viene en cero", "error si secret vacio"]
test_file_path: "functions/infra/jwt_generate_test.go"
file_path: "functions/infra/jwt_generate.go"
---
## Ejemplo
```go
claims := JWTClaims{
Subject: "user-123",
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
Custom: map[string]any{"role": "admin"},
}
token, err := JWTGenerate(claims, os.Getenv("JWT_SECRET"))
if err != nil {
return err
}
w.Header().Set("Authorization", "Bearer " + token)
```
## Notas
Impura — usa `time.Now()` para el claim `iat` cuando no viene fijado. Implementa HS256 sin libreria externa (solo stdlib crypto/hmac + crypto/sha256). Solo soporta HS256: para RS256/ES256 se crearia una funcion separada. El secret debe tener al menos 256 bits de entropia (32+ bytes aleatorios) para resistencia real. NO apto para escenarios multi-servicio donde se necesita clave publica/privada — usa RS256 en ese caso.
+54
View File
@@ -0,0 +1,54 @@
package infra
import (
"strings"
"testing"
"time"
)
func TestJWTGenerate_ReturnsThreeSegments(t *testing.T) {
claims := JWTClaims{
Subject: "user-1",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
token, err := JWTGenerate(claims, "test-secret-0123456789abcdef")
if err != nil {
t.Fatalf("JWTGenerate error: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("esperados 3 segmentos, got %d en %q", len(parts), token)
}
for i, p := range parts {
if p == "" {
t.Fatalf("segmento %d vacio", i)
}
}
}
func TestJWTGenerate_FillsIssuedAtWhenZero(t *testing.T) {
claims := JWTClaims{
Subject: "user-1",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
}
before := time.Now().Unix()
token, err := JWTGenerate(claims, "s")
if err != nil {
t.Fatalf("JWTGenerate error: %v", err)
}
after := time.Now().Unix()
parsed, err := JWTValidate(token, "s")
if err != nil {
t.Fatalf("JWTValidate error: %v", err)
}
if parsed.IssuedAt < before || parsed.IssuedAt > after {
t.Fatalf("IssuedAt %d fuera del rango [%d,%d]", parsed.IssuedAt, before, after)
}
}
func TestJWTGenerate_ErrorsOnEmptySecret(t *testing.T) {
_, err := JWTGenerate(JWTClaims{Subject: "x"}, "")
if err == nil {
t.Fatal("esperaba error con secret vacio")
}
}
+72
View File
@@ -0,0 +1,72 @@
package infra
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
// JWTValidate verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims.
// Rechaza tokens mal formados, con firma invalida o expirados (exp < time.Now()).
// Retorna las claims si todo es valido.
func JWTValidate(token string, secret string) (JWTClaims, error) {
var zero JWTClaims
if secret == "" {
return zero, errors.New("jwt_validate: secret vacio")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return zero, errors.New("jwt_validate: token malformado (se esperaban 3 segmentos)")
}
enc := base64.RawURLEncoding
signingInput := parts[0] + "." + parts[1]
// Verificar firma
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
expectedSig := mac.Sum(nil)
gotSig, err := enc.DecodeString(parts[2])
if err != nil {
return zero, errors.New("jwt_validate: firma mal codificada")
}
if !hmac.Equal(expectedSig, gotSig) {
return zero, errors.New("jwt_validate: firma invalida")
}
// Decodificar header y confirmar alg
headerBytes, err := enc.DecodeString(parts[0])
if err != nil {
return zero, errors.New("jwt_validate: header mal codificado")
}
var header map[string]string
if err := json.Unmarshal(headerBytes, &header); err != nil {
return zero, errors.New("jwt_validate: header no es JSON valido")
}
if alg, _ := header["alg"]; alg != "HS256" {
return zero, errors.New("jwt_validate: algoritmo no soportado (solo HS256)")
}
// Decodificar claims
payloadBytes, err := enc.DecodeString(parts[1])
if err != nil {
return zero, errors.New("jwt_validate: payload mal codificado")
}
var claims JWTClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return zero, errors.New("jwt_validate: payload no es JSON valido")
}
// Validar expiracion si esta presente
if claims.ExpiresAt > 0 && time.Now().Unix() >= claims.ExpiresAt {
return zero, errors.New("jwt_validate: token expirado")
}
return claims, nil
}
+45
View File
@@ -0,0 +1,45 @@
---
name: jwt_validate
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func JWTValidate(token string, secret string) (JWTClaims, error)"
description: "Verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. Rechaza tokens mal formados, con firma invalida o expirados."
tags: [jwt, auth, token, hmac, verify, infra]
uses_functions: []
uses_types: [JWTClaims_go_infra]
returns: [JWTClaims_go_infra]
returns_optional: false
error_type: error_go_core
imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, strings, time]
params:
- name: token
desc: "JWT string en formato header.payload.signature (base64url, sin padding)"
- name: secret
desc: "clave HMAC usada para firmar el token. Debe coincidir con la usada en JWTGenerate"
output: "claims decodificadas si el token es valido; error si firma invalida, expirado o malformado"
tested: true
tests: ["valida token generado por JWTGenerate", "rechaza firma invalida", "rechaza token expirado", "rechaza token malformado", "rechaza algoritmo distinto de HS256"]
test_file_path: "functions/infra/jwt_validate_test.go"
file_path: "functions/infra/jwt_validate.go"
---
## Ejemplo
```go
auth := r.Header.Get("Authorization")
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := JWTValidate(token, os.Getenv("JWT_SECRET"))
if err != nil {
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_token", Message: err.Error()})
return
}
userID := claims.Subject
role, _ := claims.Custom["role"].(string)
```
## Notas
Impura — usa `time.Now()` para comparar contra `exp`. Usa `hmac.Equal` para comparacion constant-time de firmas (mitiga timing attacks). Solo acepta alg=HS256 en el header, otros algoritmos se rechazan explicitamente para evitar el ataque "alg=none". Si `exp` es 0 (no fijado) no se valida expiracion — es responsabilidad del caller asegurar que sus tokens siempre tengan exp fijado. Errores descriptivos con prefijo `jwt_validate:` para facilitar debugging; en respuestas HTTP conviene mapear todos a un mensaje generico "token invalido" para no filtrar informacion.
+83
View File
@@ -0,0 +1,83 @@
package infra
import (
"strings"
"testing"
"time"
)
func TestJWTValidate_ValidatesGeneratedToken(t *testing.T) {
claims := JWTClaims{
Subject: "user-42",
Issuer: "tester",
ExpiresAt: time.Now().Add(time.Hour).Unix(),
Custom: map[string]any{"role": "admin"},
}
token, err := JWTGenerate(claims, "super-secret")
if err != nil {
t.Fatalf("JWTGenerate error: %v", err)
}
got, err := JWTValidate(token, "super-secret")
if err != nil {
t.Fatalf("JWTValidate error: %v", err)
}
if got.Subject != "user-42" {
t.Errorf("Subject = %q, esperado user-42", got.Subject)
}
if got.Issuer != "tester" {
t.Errorf("Issuer = %q, esperado tester", got.Issuer)
}
if got.Custom["role"] != "admin" {
t.Errorf("Custom[role] = %v, esperado admin", got.Custom["role"])
}
}
func TestJWTValidate_RejectsInvalidSignature(t *testing.T) {
token, err := JWTGenerate(JWTClaims{Subject: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret-a")
if err != nil {
t.Fatalf("JWTGenerate error: %v", err)
}
if _, err := JWTValidate(token, "secret-b"); err == nil {
t.Fatal("esperaba error con secret distinto")
}
}
func TestJWTValidate_RejectsExpiredToken(t *testing.T) {
token, err := JWTGenerate(JWTClaims{
Subject: "x",
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
}, "s")
if err != nil {
t.Fatalf("JWTGenerate error: %v", err)
}
if _, err := JWTValidate(token, "s"); err == nil {
t.Fatal("esperaba error con token expirado")
}
}
func TestJWTValidate_RejectsMalformedToken(t *testing.T) {
cases := []string{
"",
"not-a-token",
"one.two",
"one.two.three.four",
}
for _, tok := range cases {
if _, err := JWTValidate(tok, "s"); err == nil {
t.Errorf("esperaba error con token %q", tok)
}
}
}
func TestJWTValidate_RejectsOtherAlgorithms(t *testing.T) {
// Token con alg=none no es aceptado aunque la firma sea vacia
// Construimos manualmente: header con alg=none
// "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" = {"alg":"none","typ":"JWT"}
token := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ4In0."
if _, err := JWTValidate(token, "s"); err == nil {
t.Fatal("esperaba error con alg=none")
}
if !strings.Contains(token, ".") {
t.Fatal("token debe tener puntos")
}
}
+16
View File
@@ -0,0 +1,16 @@
package infra
import "golang.org/x/crypto/bcrypt"
// PasswordHash hashea un password con bcrypt.
// cost controla el trabajo computacional (4 = minimo, 14 = muy lento). Valor 0 usa default 12.
func PasswordHash(password string, cost int) (string, error) {
if cost <= 0 {
cost = 12
}
b, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
}
return string(b), nil
}
+41
View File
@@ -0,0 +1,41 @@
---
name: password_hash
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PasswordHash(password string, cost int) (string, error)"
description: "Hashea un password con bcrypt. Cost por defecto es 12 (si se pasa 0). El hash resultante incluye el salt y el cost embebidos."
tags: [password, hash, bcrypt, auth, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [golang.org/x/crypto/bcrypt]
params:
- name: password
desc: "password en texto plano a hashear"
- name: cost
desc: "coste bcrypt entre 4 y 14. 0 usa el default 12 (buen balance velocidad/seguridad en 2025)"
output: "hash bcrypt en formato $2a$... apto para guardar en BD y verificar con PasswordVerify"
tested: true
tests: ["hashea password con cost default", "hashea password con cost custom", "hashes distintos para mismo password (salt diferente)"]
test_file_path: "functions/infra/password_hash_test.go"
file_path: "functions/infra/password_hash.go"
---
## Ejemplo
```go
hash, err := PasswordHash(inputPassword, 12)
if err != nil {
return err
}
db.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, hash)
```
## Notas
Impura — bcrypt usa entropia del OS para generar salt aleatorio en cada invocacion. El hash producido incluye el salt y el cost embebidos en el string (`$2a$12$salt...hash`), por lo que PasswordVerify no necesita el cost como parametro aparte. Cost 12 = ~250ms/hash en hardware moderno (2025): suficiente para bloquear ataques por fuerza bruta sin ser insoportable en el login. Para proteccion extra en servidores con mucho CPU disponible se puede subir a 14.
+34
View File
@@ -0,0 +1,34 @@
package infra
import (
"strings"
"testing"
)
func TestPasswordHash_DefaultCost(t *testing.T) {
hash, err := PasswordHash("hunter2", 0)
if err != nil {
t.Fatalf("PasswordHash error: %v", err)
}
if !strings.HasPrefix(hash, "$2") {
t.Errorf("hash no tiene prefijo bcrypt: %q", hash)
}
}
func TestPasswordHash_CustomCost(t *testing.T) {
hash, err := PasswordHash("password", 4) // 4 = minimum, rapido para tests
if err != nil {
t.Fatalf("PasswordHash error: %v", err)
}
if hash == "" {
t.Fatal("hash vacio")
}
}
func TestPasswordHash_DifferentSalts(t *testing.T) {
h1, _ := PasswordHash("same-password", 4)
h2, _ := PasswordHash("same-password", 4)
if h1 == h2 {
t.Fatal("los hashes deben diferir por salt distinto")
}
}
+9
View File
@@ -0,0 +1,9 @@
package infra
import "golang.org/x/crypto/bcrypt"
// PasswordVerify compara un password en texto plano contra un hash bcrypt.
// Retorna nil si hacen match, error si no.
func PasswordVerify(password string, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
+47
View File
@@ -0,0 +1,47 @@
---
name: password_verify
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func PasswordVerify(password string, hash string) error"
description: "Verifica un password en texto plano contra un hash bcrypt. Retorna nil si hacen match, error si no coinciden o si el hash es invalido."
tags: [password, verify, bcrypt, auth, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [golang.org/x/crypto/bcrypt]
params:
- name: password
desc: "password en texto plano a verificar"
- name: hash
desc: "hash bcrypt obtenido previamente de PasswordHash (guardado en BD)"
output: "nil si el password coincide con el hash; error si no coincide o hash invalido"
tested: true
tests: ["verifica password correcto", "rechaza password incorrecto", "rechaza hash malformado"]
test_file_path: "functions/infra/password_verify_test.go"
file_path: "functions/infra/password_verify.go"
---
## Ejemplo
```go
row := db.QueryRow("SELECT password_hash FROM users WHERE email = ?", email)
var stored string
if err := row.Scan(&stored); err != nil {
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
return
}
if err := PasswordVerify(input, stored); err != nil {
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
return
}
// OK, emitir token
```
## Notas
Impura — bcrypt.CompareHashAndPassword es constant-time internamente (mitiga timing attacks). En respuestas HTTP al usuario NO distinguir entre "email no existe" y "password incorrecto": ambos casos deben retornar el mismo mensaje generico para no filtrar existencia de cuentas. El error real se puede loguear internamente con log_info/log_warn sin problema.
+23
View File
@@ -0,0 +1,23 @@
package infra
import "testing"
func TestPasswordVerify_CorrectPassword(t *testing.T) {
hash, _ := PasswordHash("correct-horse-battery-staple", 4)
if err := PasswordVerify("correct-horse-battery-staple", hash); err != nil {
t.Fatalf("esperaba nil, got %v", err)
}
}
func TestPasswordVerify_WrongPassword(t *testing.T) {
hash, _ := PasswordHash("secret", 4)
if err := PasswordVerify("wrong", hash); err == nil {
t.Fatal("esperaba error con password incorrecto")
}
}
func TestPasswordVerify_InvalidHash(t *testing.T) {
if err := PasswordVerify("x", "not-a-bcrypt-hash"); err == nil {
t.Fatal("esperaba error con hash invalido")
}
}
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/jackc/pgx/v5 v5.9.1
github.com/marcboeker/go-duckdb v1.8.5
github.com/mattn/go-sqlite3 v1.14.37
golang.org/x/crypto v0.48.0
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17
+2
View File
@@ -155,6 +155,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=