Backend Phase B (1/2): password auth + sessions

Argon2id password hashing (PHC format, self-encoded/decoded without an
external lib) with constant-time verification, UserStore (create/get by
email and id) and SessionStore (opaque crypto/rand tokens, SHA-256 hashed
in DB, create/lookup/revoke, last_seen_at bump on lookup).

HTTP layer: Register/Login/Logout handlers + RequireAuth middleware.
Login uses a dummy-hash path so unknown-email and wrong-password yield the
same timing/shape, narrowing user enumeration. Tokens accepted via Bearer
header (native clients) or session cookie (HttpOnly, SameSite=Lax).

Routes wired in api.go: POST /auth/register, /auth/login, /auth/logout.
Verified with go test, go vet and an end-to-end smoke test against a real
PostgreSQL container (register/login/logout/duplicate/short-pw/wrong-pw).

OIDC (Phase B part 2) follows next; the issueSession helper is reused.
This commit is contained in:
Tronax 2026-08-05 19:05:07 +02:00
parent 2899eb205b
commit 7b1c18590e
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
10 changed files with 756 additions and 29 deletions

View file

@ -0,0 +1,137 @@
// Package auth implements password hashing, session management and (later) OIDC
// verification for the mitbringsl backend.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2Params holds the cost parameters for the Argon2id key derivation.
// Defaults follow the RFC-9106 "first recommended" option for memory-hard
// single-instance hashing (m=64MiB, t=3, p=4). They are deliberately not
// configurable via env to avoid accidental downgrades; change via code instead.
type Argon2Params struct {
Memory uint32 // in KiB
Iterations uint32
Parallelism uint8
SaltLength uint32 // bytes
KeyLength uint32 // bytes
}
// DefaultArgon2Params are tuned for a backend with ~256MiB headroom per login.
// Memory = 64 * 1024 KiB = 64 MiB.
var DefaultArgon2Params = Argon2Params{
Memory: 64 * 1024,
Iterations: 3,
Parallelism: 4,
SaltLength: 16,
KeyLength: 32,
}
// ErrInvalidHash is returned when a stored password hash is not a valid PHC string.
var ErrInvalidHash = errors.New("invalid password hash")
// HashPassword derives an Argon2id hash from the password and encodes it in the
// PHC string format: $argon2id$v=19$m=<m>,t=<t>,p=<p>$<salt_b64>$<hash_b64>.
// The returned string is safe to store verbatim in the users.password_hash column.
func HashPassword(password string, p Argon2Params) (string, error) {
salt := make([]byte, p.SaltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength)
return encodePHC(p, salt, key), nil
}
// VerifyPassword compares a password against a stored PHC-format hash in constant
// time. It returns a non-nil error if the hash is malformed or the password does
// not match.
func VerifyPassword(password, encoded string) error {
p, salt, hash, err := decodePHC(encoded)
if err != nil {
return err
}
otherKey := argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, uint32(len(hash)))
if subtle.ConstantTimeCompare(hash, otherKey) != 1 {
return errors.New("password does not match")
}
return nil
}
// --- PHC encoding helpers ----------------------------------------------------
const phcAlg = "argon2id"
func encodePHC(p Argon2Params, salt, key []byte) string {
b64 := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$%s$v=%d$m=%d,t=%d,p=%d$%s$%s",
phcAlg, argon2.Version, p.Memory, p.Iterations, p.Parallelism,
b64(salt), b64(key))
}
func decodePHC(encoded string) (Argon2Params, []byte, []byte, error) {
parts := strings.Split(encoded, "$")
// Expected: ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<hash>"]
if len(parts) != 6 || parts[1] != phcAlg {
return Argon2Params{}, nil, nil, fmt.Errorf("%w: wrong format", ErrInvalidHash)
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return Argon2Params{}, nil, nil, fmt.Errorf("%w: parse version: %v", ErrInvalidHash, err)
}
if version != argon2.Version {
return Argon2Params{}, nil, nil, fmt.Errorf("%w: unsupported version %d", ErrInvalidHash, version)
}
p, err := parseParams(parts[3])
if err != nil {
return Argon2Params{}, nil, nil, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return Argon2Params{}, nil, nil, fmt.Errorf("%w: decode salt: %v", ErrInvalidHash, err)
}
key, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return Argon2Params{}, nil, nil, fmt.Errorf("%w: decode key: %v", ErrInvalidHash, err)
}
p.KeyLength = uint32(len(key))
p.SaltLength = uint32(len(salt))
return p, salt, key, nil
}
func parseParams(s string) (Argon2Params, error) {
var p Argon2Params
for _, kv := range strings.Split(s, ",") {
k, v, ok := strings.Cut(kv, "=")
if !ok {
return Argon2Params{}, fmt.Errorf("%w: malformed param %q", ErrInvalidHash, kv)
}
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return Argon2Params{}, fmt.Errorf("%w: parse %s: %v", ErrInvalidHash, k, err)
}
switch k {
case "m":
p.Memory = uint32(n)
case "t":
p.Iterations = uint32(n)
case "p":
p.Parallelism = uint8(n)
}
}
if p.Memory == 0 || p.Iterations == 0 || p.Parallelism == 0 {
return Argon2Params{}, fmt.Errorf("%w: missing cost parameter", ErrInvalidHash)
}
return p, nil
}

View file

@ -0,0 +1,67 @@
package auth
import (
"strings"
"testing"
)
// fastParams keeps the test fast while still exercising the full PHC path.
var fastParams = Argon2Params{Memory: 4 * 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32}
func TestHashPassword_PHCFormat(t *testing.T) {
h, err := HashPassword("hunter2", fastParams)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(h, "$argon2id$v=19$") {
t.Fatalf("unexpected PHC prefix: %s", h)
}
if strings.Count(h, "$") != 5 {
t.Fatalf("expected 5 '$' separators, got %q", h)
}
}
func TestVerifyPassword_RoundTrip(t *testing.T) {
pw := "correct horse battery staple"
h, err := HashPassword(pw, fastParams)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := VerifyPassword(pw, h); err != nil {
t.Fatalf("VerifyPassword correct: %v", err)
}
if err := VerifyPassword("wrong", h); err == nil {
t.Fatal("VerifyPassword wrong: expected error, got nil")
}
}
func TestVerifyPassword_Malformed(t *testing.T) {
cases := []string{
"",
"not-a-hash",
"$argon2id$v=19$m=1024,t=1,p=1$AAAA$BB",
"$argon2i$v=19$m=1024,t=1,p=1$AAAA$BBBB",
"$argon2id$v=99$m=1024,t=1,p=1$AAAA$BBBB",
"$argon2id$v=19$m=0,t=1,p=1$AAAA$BBBB",
}
for _, c := range cases {
if err := VerifyPassword("x", c); err == nil {
t.Fatalf("VerifyPassword(%q): expected error", c)
}
}
}
func TestHashPassword_DifferentSalts(t *testing.T) {
a, _ := HashPassword("same", fastParams)
b, _ := HashPassword("same", fastParams)
if a == b {
t.Fatal("two hashes of the same password should differ due to random salt")
}
// both must still verify against the original password
if err := VerifyPassword("same", a); err != nil {
t.Fatalf("verify a: %v", err)
}
if err := VerifyPassword("same", b); err != nil {
t.Fatalf("verify b: %v", err)
}
}

View file

@ -0,0 +1,14 @@
package auth
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
// isUniqueViolation reports whether err is a PostgreSQL unique_violation (SQLSTATE 23505),
// e.g. from a duplicate INSERT on a UNIQUE column.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

View file

@ -0,0 +1,117 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Session is the application-side view of a row in the sessions table.
type Session struct {
ID uuid.UUID
UserID uuid.UUID
ExpiresAt time.Time
RevokedAt *time.Time
}
// ErrSessionNotFound is returned when no (valid, non-expired, non-revoked)
// session matches the given token.
var ErrSessionNotFound = errors.New("session not found")
// SessionConfig controls token generation and persistence.
type SessionConfig struct {
TokenBytes int // entropy of the raw token before base64url encoding
TTL time.Duration // validity window from creation
}
// SessionStore wraps database access for the sessions table and handles opaque
// token generation. Only the SHA-256 hash of a token is ever stored.
type SessionStore struct {
pool *pgxpool.Pool
cfg SessionConfig
}
// NewSessionStore constructs a SessionStore. tokenBytes must be >= 16.
func NewSessionStore(pool *pgxpool.Pool, cfg SessionConfig) *SessionStore {
if cfg.TokenBytes < 16 {
cfg.TokenBytes = 32
}
return &SessionStore{pool: pool, cfg: cfg}
}
// Create issues a new session for the user and returns the raw token (to send to
// the client exactly once) together with the persisted Session row.
func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, userAgent, ip string) (token string, sess Session, err error) {
raw := make([]byte, s.cfg.TokenBytes)
if _, err = rand.Read(raw); err != nil {
return "", Session{}, fmt.Errorf("generate session token: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(raw)
tokenHash := hashToken(token)
expiresAt := time.Now().Add(s.cfg.TTL)
const q = `
INSERT INTO sessions (user_id, token_hash, expires_at, user_agent, ip)
VALUES ($1, $2, $3, NULLIF($4, ''), NULLIF($5, ''))
RETURNING id, user_id, expires_at, revoked_at`
err = s.pool.QueryRow(ctx, q, userID, tokenHash, expiresAt, userAgent, ip).
Scan(&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt)
if err != nil {
return "", Session{}, fmt.Errorf("insert session: %w", err)
}
return token, sess, nil
}
// Lookup returns the active session for a raw token. It rejects expired and
// revoked sessions and updates last_seen_at (best-effort, errors logged by caller).
func (s *SessionStore) Lookup(ctx context.Context, token string) (Session, error) {
if token == "" {
return Session{}, ErrSessionNotFound
}
const q = `
UPDATE sessions
SET last_seen_at = now()
WHERE token_hash = $1
AND revoked_at IS NULL
AND expires_at > now()
RETURNING id, user_id, expires_at, revoked_at`
var sess Session
err := s.pool.QueryRow(ctx, q, hashToken(token)).Scan(
&sess.ID, &sess.UserID, &sess.ExpiresAt, &sess.RevokedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrSessionNotFound
}
return Session{}, fmt.Errorf("lookup session: %w", err)
}
return sess, nil
}
// Revoke marks the session matching token as revoked. Missing tokens are a no-op
// so logout stays idempotent.
func (s *SessionStore) Revoke(ctx context.Context, token string) error {
if token == "" {
return nil
}
const q = `UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`
_, err := s.pool.Exec(ctx, q, hashToken(token))
if err != nil {
return fmt.Errorf("revoke session: %w", err)
}
return nil
}
// hashToken returns the lowercase hex SHA-256 digest of a raw token. The hash is
// what we store; the raw token never touches the database.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}

View file

@ -0,0 +1,95 @@
package auth
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// User is the application-side view of a row in the users table.
type User struct {
ID uuid.UUID
Email string
PasswordHash *string // nil for OIDC-only accounts
OIDCSubject *string
OIDCIssuer *string
DisplayName *string
}
// ErrUserNotFound is returned when no user matches the query.
var ErrUserNotFound = errors.New("user not found")
// UserStore wraps database access for the users table.
type UserStore struct {
pool *pgxpool.Pool
}
// NewUserStore constructs a UserStore backed by the given pool.
func NewUserStore(pool *pgxpool.Pool) *UserStore {
return &UserStore{pool: pool}
}
// CreateUser inserts a new user with a password hash (email/password accounts).
// It returns the created user. Email uniqueness violations surface as ErrEmailTaken.
var ErrEmailTaken = errors.New("email already registered")
func (s *UserStore) CreateUser(ctx context.Context, email, passwordHash, displayName string) (User, error) {
const q = `
INSERT INTO users (email, password_hash, display_name)
VALUES ($1, $2, NULLIF($3, ''))
RETURNING id, email, password_hash, oidc_subject, oidc_issuer, display_name`
var u User
var dn *string
err := s.pool.QueryRow(ctx, q, email, passwordHash, displayName).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn)
if err != nil {
if isUniqueViolation(err) {
return User{}, fmt.Errorf("%w: %s", ErrEmailTaken, email)
}
return User{}, fmt.Errorf("create user: %w", err)
}
u.DisplayName = dn
return u, nil
}
// GetUserByEmail loads a user by its (case-sensitive) email address.
func (s *UserStore) GetUserByEmail(ctx context.Context, email string) (User, error) {
const q = `
SELECT id, email, password_hash, oidc_subject, oidc_issuer, display_name
FROM users WHERE email = $1`
var u User
var dn *string
err := s.pool.QueryRow(ctx, q, email).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
return User{}, fmt.Errorf("get user by email: %w", err)
}
u.DisplayName = dn
return u, nil
}
// GetUserByID loads a user by its primary key.
func (s *UserStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) {
const q = `
SELECT id, email, password_hash, oidc_subject, oidc_issuer, display_name
FROM users WHERE id = $1`
var u User
var dn *string
err := s.pool.QueryRow(ctx, q, id).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUserNotFound
}
return User{}, fmt.Errorf("get user by id: %w", err)
}
u.DisplayName = dn
return u, nil
}