From 286b886c0656539e645c1306687f89d12cbe6523 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 19:49:07 -0700 Subject: [PATCH] Implement Phase 2: password auth (Argon2id + bearer tokens) - Argon2id password hashing and verification with configurable params - Bearer token generation (32-byte random), SHA-256 hashed storage, TTL-based expiry - User creation and authentication helpers - auth_tokens table added to migrations - 6 tests: hash/verify, wrong password, create/auth user, token create/validate, token expiry Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 1 + go.sum | 2 + internal/auth/argon2.go | 77 ++++++++++++++++++++++ internal/auth/auth_test.go | 128 +++++++++++++++++++++++++++++++++++++ internal/auth/tokens.go | 67 +++++++++++++++++++ internal/auth/users.go | 48 ++++++++++++++ internal/db/migrations.go | 9 +++ internal/db/testhelper.go | 8 +++ 8 files changed, 340 insertions(+) create mode 100644 internal/auth/argon2.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/tokens.go create mode 100644 internal/auth/users.go create mode 100644 internal/db/testhelper.go diff --git a/go.mod b/go.mod index 1f6e4da..30f824f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/pelletier/go-toml/v2 v2.3.0 github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.49.0 modernc.org/sqlite v1.47.0 ) diff --git a/go.sum b/go.sum index 3d67762..b60e67d 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/internal/auth/argon2.go b/internal/auth/argon2.go new file mode 100644 index 0000000..6afcb0b --- /dev/null +++ b/internal/auth/argon2.go @@ -0,0 +1,77 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +type Argon2Params struct { + Memory uint32 + Time uint32 + Threads uint8 +} + +var DefaultParams = Argon2Params{ + Memory: 65536, + Time: 3, + Threads: 4, +} + +const saltLen = 16 +const keyLen = 32 + +// HashPassword generates an Argon2id hash string. +func HashPassword(password string, params Argon2Params) (string, error) { + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + + key := argon2.IDKey([]byte(password), salt, params.Time, params.Memory, params.Threads, keyLen) + + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + params.Memory, params.Time, params.Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// VerifyPassword checks a password against an Argon2id hash string. +func VerifyPassword(password, hash string) (bool, error) { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, fmt.Errorf("invalid hash format") + } + + var v int + if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil { + return false, fmt.Errorf("parse version: %w", err) + } + + var memory uint32 + var time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, fmt.Errorf("parse params: %w", err) + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("decode salt: %w", err) + } + + expectedKey, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("decode key: %w", err) + } + + key := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(expectedKey))) + + return subtle.ConstantTimeCompare(key, expectedKey) == 1, nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..b8dcb4b --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,128 @@ +package auth + +import ( + "path/filepath" + "testing" + "time" + + "git.wntrmute.dev/kyle/eng-pad-server/internal/db" +) + +func setupTestDB(t *testing.T) *db.TestDB { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return &db.TestDB{DB: database} +} + +func TestHashAndVerify(t *testing.T) { + hash, err := HashPassword("testpassword", DefaultParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + + ok, err := VerifyPassword("testpassword", hash) + if err != nil { + t.Fatalf("verify: %v", err) + } + if !ok { + t.Fatal("expected password to verify") + } +} + +func TestVerifyWrongPassword(t *testing.T) { + hash, err := HashPassword("correct", DefaultParams) + if err != nil { + t.Fatalf("hash: %v", err) + } + + ok, err := VerifyPassword("wrong", hash) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ok { + t.Fatal("expected password to not verify") + } +} + +func TestCreateAndAuthenticateUser(t *testing.T) { + tdb := setupTestDB(t) + + id, err := CreateUser(tdb.DB, "alice", "hunter2", DefaultParams) + if err != nil { + t.Fatalf("create user: %v", err) + } + if id <= 0 { + t.Fatalf("expected positive ID, got %d", id) + } + + authID, err := AuthenticateUser(tdb.DB, "alice", "hunter2") + if err != nil { + t.Fatalf("auth: %v", err) + } + if authID != id { + t.Fatalf("expected user ID %d, got %d", id, authID) + } +} + +func TestAuthenticateWrongPassword(t *testing.T) { + tdb := setupTestDB(t) + + _, err := CreateUser(tdb.DB, "alice", "hunter2", DefaultParams) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, err = AuthenticateUser(tdb.DB, "alice", "wrong") + if err == nil { + t.Fatal("expected error for wrong password") + } +} + +func TestTokenCreateAndValidate(t *testing.T) { + tdb := setupTestDB(t) + + userID, err := CreateUser(tdb.DB, "alice", "pass", DefaultParams) + if err != nil { + t.Fatalf("create user: %v", err) + } + + token, err := CreateToken(tdb.DB, userID, time.Hour) + if err != nil { + t.Fatalf("create token: %v", err) + } + + gotID, err := ValidateToken(tdb.DB, token) + if err != nil { + t.Fatalf("validate: %v", err) + } + if gotID != userID { + t.Fatalf("expected user %d, got %d", userID, gotID) + } +} + +func TestTokenExpired(t *testing.T) { + tdb := setupTestDB(t) + + userID, err := CreateUser(tdb.DB, "alice", "pass", DefaultParams) + if err != nil { + t.Fatalf("create user: %v", err) + } + + token, err := CreateToken(tdb.DB, userID, -time.Hour) // Already expired + if err != nil { + t.Fatalf("create token: %v", err) + } + + _, err = ValidateToken(tdb.DB, token) + if err == nil { + t.Fatal("expected error for expired token") + } +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..449ad02 --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,67 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "time" +) + +const tokenBytes = 32 + +// CreateToken generates a bearer token and stores its hash. +func CreateToken(database *sql.DB, userID int64, ttl time.Duration) (string, error) { + raw := make([]byte, tokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate token: %w", err) + } + + token := hex.EncodeToString(raw) + hash := hashToken(token) + expiresAt := time.Now().Add(ttl).UnixMilli() + + _, err := database.Exec( + "INSERT INTO auth_tokens (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)", + hash, userID, expiresAt, time.Now().UnixMilli(), + ) + if err != nil { + return "", fmt.Errorf("store token: %w", err) + } + + return token, nil +} + +// ValidateToken checks a bearer token and returns the user ID. +func ValidateToken(database *sql.DB, token string) (int64, error) { + hash := hashToken(token) + var userID int64 + var expiresAt int64 + err := database.QueryRow( + "SELECT user_id, expires_at FROM auth_tokens WHERE token_hash = ?", hash, + ).Scan(&userID, &expiresAt) + if err != nil { + return 0, fmt.Errorf("token not found") + } + + if time.Now().UnixMilli() > expiresAt { + // Clean up expired token + _, _ = database.Exec("DELETE FROM auth_tokens WHERE token_hash = ?", hash) + return 0, fmt.Errorf("token expired") + } + + return userID, nil +} + +// DeleteToken revokes a bearer token. +func DeleteToken(database *sql.DB, token string) error { + hash := hashToken(token) + _, err := database.Exec("DELETE FROM auth_tokens WHERE token_hash = ?", hash) + return err +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/auth/users.go b/internal/auth/users.go new file mode 100644 index 0000000..8e22763 --- /dev/null +++ b/internal/auth/users.go @@ -0,0 +1,48 @@ +package auth + +import ( + "database/sql" + "fmt" + "time" +) + +// CreateUser creates a new user with a hashed password. +func CreateUser(database *sql.DB, username, password string, params Argon2Params) (int64, error) { + hash, err := HashPassword(password, params) + if err != nil { + return 0, err + } + + now := time.Now().UnixMilli() + res, err := database.Exec( + "INSERT INTO users (username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?)", + username, hash, now, now, + ) + if err != nil { + return 0, fmt.Errorf("insert user: %w", err) + } + + return res.LastInsertId() +} + +// AuthenticateUser verifies username/password and returns the user ID. +func AuthenticateUser(database *sql.DB, username, password string) (int64, error) { + var userID int64 + var hash string + err := database.QueryRow( + "SELECT id, password_hash FROM users WHERE username = ?", username, + ).Scan(&userID, &hash) + if err != nil { + return 0, fmt.Errorf("user not found") + } + + ok, err := VerifyPassword(password, hash) + if err != nil { + return 0, err + } + if !ok { + return 0, fmt.Errorf("invalid password") + } + + return userID, nil +} diff --git a/internal/db/migrations.go b/internal/db/migrations.go index 25aeb1a..0fecf9b 100644 --- a/internal/db/migrations.go +++ b/internal/db/migrations.go @@ -76,6 +76,15 @@ CREATE INDEX IF NOT EXISTS idx_pages_notebook ON pages(notebook_id); CREATE INDEX IF NOT EXISTS idx_strokes_page ON strokes(page_id); CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token); CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); `, }, } diff --git a/internal/db/testhelper.go b/internal/db/testhelper.go new file mode 100644 index 0000000..9d494c5 --- /dev/null +++ b/internal/db/testhelper.go @@ -0,0 +1,8 @@ +package db + +import "database/sql" + +// TestDB wraps a *sql.DB for use in tests. +type TestDB struct { + DB *sql.DB +}