✨ feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3)
Adds the persistence layer for the passwordless-auth flow. The token VALUE is never stored — only its sha256 hex digest, mirroring ADR-0021 secret retention via fingerprint. Plaintext is generated server-side, emailed once, and rehashed on consume. Repository methods : Create / GetByHash / MarkConsumed / DeleteExpired. AutoMigrate wired in both PostgresRepository init paths (DSN-built + cfg-built). Tests : - 5 unit tests : token generation shape, URL-safety, uniqueness, hash stability - 5 integration tests (build tag `integration`) : end-to-end against real Postgres, cover the happy path, missing-hash, consume idempotency, expired-cleanup, and the unique-index defensive check
This commit is contained in:
150
pkg/user/magic_link.go
Normal file
150
pkg/user/magic_link.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MagicLinkToken is the persistent record of a passwordless-auth token.
|
||||
//
|
||||
// Per ADR-0028 Phase A: the token VALUE is never stored. Only its SHA-256
|
||||
// hash sits in the DB ; if the table leaks, the attacker has no usable
|
||||
// tokens (mirrors ADR-0021 secret retention via fingerprint approach).
|
||||
//
|
||||
// The plaintext token is delivered to the user exactly once via email and
|
||||
// must be supplied back through the consume endpoint to re-derive the
|
||||
// hash and find the row.
|
||||
type MagicLinkToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;not null;index"`
|
||||
Email string `gorm:"not null;index"`
|
||||
TokenHash string `gorm:"not null;uniqueIndex;size:64"` // hex-encoded sha256 = 64 chars
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
ConsumedAt *time.Time `gorm:""`
|
||||
}
|
||||
|
||||
// MagicLinkRepository is the persistence contract for magic-link tokens.
|
||||
// PostgresRepository implements it ; tests can use a fake.
|
||||
type MagicLinkRepository interface {
|
||||
CreateMagicLinkToken(ctx context.Context, token *MagicLinkToken) error
|
||||
GetMagicLinkTokenByHash(ctx context.Context, tokenHash string) (*MagicLinkToken, error)
|
||||
MarkMagicLinkTokenConsumed(ctx context.Context, id uint, consumedAt time.Time) error
|
||||
DeleteExpiredMagicLinkTokens(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// GenerateMagicLinkToken returns a fresh url-safe random token suitable
|
||||
// for inclusion in an email link, plus its SHA-256 hex digest for storage.
|
||||
//
|
||||
// The plaintext is what gets emailed ; the hash is what gets persisted.
|
||||
// 32 bytes of entropy = 256 bits ; collision-resistant for our scale.
|
||||
func GenerateMagicLinkToken() (plaintext, hashHex string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", "", fmt.Errorf("magic link rand: %w", err)
|
||||
}
|
||||
plaintext = base64.RawURLEncoding.EncodeToString(buf)
|
||||
hashHex = HashMagicLinkToken(plaintext)
|
||||
return plaintext, hashHex, nil
|
||||
}
|
||||
|
||||
// HashMagicLinkToken returns the lowercase hex sha256 of token. Stable
|
||||
// over time : the same plaintext always maps to the same hash, so
|
||||
// consume can re-derive and look up the row.
|
||||
func HashMagicLinkToken(plaintext string) string {
|
||||
sum := sha256.Sum256([]byte(plaintext))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreateMagicLinkToken persists a magic-link token. The caller is
|
||||
// responsible for hashing the plaintext (cf. HashMagicLinkToken) and
|
||||
// setting ExpiresAt ; this method does not generate either.
|
||||
func (r *PostgresRepository) CreateMagicLinkToken(ctx context.Context, token *MagicLinkToken) error {
|
||||
ctx, span := r.createSpan(ctx, "create_magic_link_token")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("email", token.Email))
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(token).Error; err != nil {
|
||||
if span != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return fmt.Errorf("failed to create magic link token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMagicLinkTokenByHash looks up a magic-link token by its hex sha256.
|
||||
// Returns (nil, nil) when no row matches — callers must treat that as
|
||||
// "invalid token" and respond with the same generic error as "expired"
|
||||
// or "consumed" to avoid leaking which condition failed.
|
||||
func (r *PostgresRepository) GetMagicLinkTokenByHash(ctx context.Context, tokenHash string) (*MagicLinkToken, error) {
|
||||
ctx, span := r.createSpan(ctx, "get_magic_link_token_by_hash")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
}
|
||||
var t MagicLinkToken
|
||||
err := r.db.WithContext(ctx).Where("token_hash = ?", tokenHash).First(&t).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if span != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get magic link token: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// MarkMagicLinkTokenConsumed sets consumed_at on the row with the given
|
||||
// ID. Idempotent only at the SQL-engine level — the consume handler is
|
||||
// responsible for refusing to act when consumed_at is already set.
|
||||
func (r *PostgresRepository) MarkMagicLinkTokenConsumed(ctx context.Context, id uint, consumedAt time.Time) error {
|
||||
ctx, span := r.createSpan(ctx, "mark_magic_link_token_consumed")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&MagicLinkToken{}).
|
||||
Where("id = ?", id).
|
||||
Update("consumed_at", consumedAt)
|
||||
if res.Error != nil {
|
||||
if span != nil {
|
||||
span.RecordError(res.Error)
|
||||
}
|
||||
return fmt.Errorf("failed to mark magic link token consumed: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return fmt.Errorf("no magic link token with id=%d", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredMagicLinkTokens removes rows whose expires_at is strictly
|
||||
// before the given cutoff. Returns the count deleted. Used by the
|
||||
// scheduled cleanup job.
|
||||
func (r *PostgresRepository) DeleteExpiredMagicLinkTokens(ctx context.Context, before time.Time) (int64, error) {
|
||||
ctx, span := r.createSpan(ctx, "delete_expired_magic_link_tokens")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
}
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("expires_at < ?", before).
|
||||
Delete(&MagicLinkToken{})
|
||||
if res.Error != nil {
|
||||
if span != nil {
|
||||
span.RecordError(res.Error)
|
||||
}
|
||||
return 0, fmt.Errorf("failed to delete expired magic link tokens: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
194
pkg/user/magic_link_integration_test.go
Normal file
194
pkg/user/magic_link_integration_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests for the magic-link repository methods. Run with:
|
||||
//
|
||||
// go test -tags integration ./pkg/user/...
|
||||
//
|
||||
// Requires a running Postgres reachable via the same env vars / defaults
|
||||
// the BDD suite already uses (DLC_DATABASE_HOST, etc., default
|
||||
// localhost:5432 from docker-compose).
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dance-lessons-coach/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// freshRepo connects to the local Postgres, creates a uniquely-named
|
||||
// schema for THIS test, and returns a repository scoped to it.
|
||||
// On test end, the schema is dropped (cleanup is best-effort).
|
||||
func freshRepo(t *testing.T) *PostgresRepository {
|
||||
t.Helper()
|
||||
cfg, err := config.LoadConfig()
|
||||
require.NoError(t, err)
|
||||
|
||||
var raw [6]byte
|
||||
_, err = rand.Read(raw[:])
|
||||
require.NoError(t, err)
|
||||
schema := "ml_test_" + hex.EncodeToString(raw[:])
|
||||
|
||||
// Bootstrap schema via a default-DSN repo (no search_path).
|
||||
bootDSN := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.GetDatabaseHost(),
|
||||
cfg.GetDatabasePort(),
|
||||
cfg.GetDatabaseUser(),
|
||||
cfg.GetDatabasePassword(),
|
||||
cfg.GetDatabaseName(),
|
||||
cfg.GetDatabaseSSLMode(),
|
||||
)
|
||||
bootRepo, err := NewPostgresRepositoryFromDSN(cfg, bootDSN)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, bootRepo.Exec(fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = bootRepo.Exec(fmt.Sprintf(`DROP SCHEMA "%s" CASCADE`, schema))
|
||||
})
|
||||
|
||||
dsn := BuildSchemaIsolatedDSN(cfg, schema)
|
||||
repo, err := NewPostgresRepositoryFromDSN(cfg, dsn)
|
||||
require.NoError(t, err)
|
||||
return repo
|
||||
}
|
||||
|
||||
// TestMagicLinkRepo_CreateAndGetByHash is the end-to-end happy path :
|
||||
// store a token, look it up by hash, get the row back.
|
||||
func TestMagicLinkRepo_CreateAndGetByHash(t *testing.T) {
|
||||
repo := freshRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
plain, hashHex, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
tok := &MagicLinkToken{
|
||||
Email: "alice@example.com",
|
||||
TokenHash: hashHex,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
|
||||
assert.NotZero(t, tok.ID, "ID should be populated by GORM after Create")
|
||||
|
||||
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got, "fresh token must be retrievable")
|
||||
assert.Equal(t, "alice@example.com", got.Email)
|
||||
assert.Nil(t, got.ConsumedAt, "fresh token is not yet consumed")
|
||||
|
||||
// Lookup by the plaintext (which the consume handler does NOT receive
|
||||
// directly — it must hash first). This confirms the hashing direction
|
||||
// is consistent.
|
||||
got2, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken(plain))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got2)
|
||||
assert.Equal(t, tok.ID, got2.ID)
|
||||
}
|
||||
|
||||
// TestMagicLinkRepo_GetByHash_Missing returns (nil, nil) for a hash that
|
||||
// never existed. Callers must NOT distinguish "missing" from "expired"
|
||||
// or "consumed" — they all collapse to a single generic error to the user.
|
||||
func TestMagicLinkRepo_GetByHash_Missing(t *testing.T) {
|
||||
repo := freshRepo(t)
|
||||
got, err := repo.GetMagicLinkTokenByHash(context.Background(), HashMagicLinkToken("never-issued"))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
// TestMagicLinkRepo_MarkConsumed flips consumed_at and refuses to act
|
||||
// on a non-existent ID.
|
||||
func TestMagicLinkRepo_MarkConsumed(t *testing.T) {
|
||||
repo := freshRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, hashHex, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
tok := &MagicLinkToken{
|
||||
Email: "bob@example.com",
|
||||
TokenHash: hashHex,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
require.NoError(t, repo.MarkMagicLinkTokenConsumed(ctx, tok.ID, now))
|
||||
|
||||
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.ConsumedAt, "consumed_at must be set")
|
||||
assert.WithinDuration(t, now, got.ConsumedAt.UTC(), time.Second)
|
||||
|
||||
// Marking a non-existent ID returns an error (defensive — the consume
|
||||
// handler should never call us with a fake ID, but if it does we want
|
||||
// the failure to be loud).
|
||||
err = repo.MarkMagicLinkTokenConsumed(ctx, 999999, time.Now())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestMagicLinkRepo_DeleteExpired confirms the cleanup pass deletes
|
||||
// strictly-before-cutoff rows and leaves future ones alone.
|
||||
func TestMagicLinkRepo_DeleteExpired(t *testing.T) {
|
||||
repo := freshRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
expired := &MagicLinkToken{
|
||||
Email: "expired@example.com",
|
||||
TokenHash: HashMagicLinkToken("expired-token"),
|
||||
ExpiresAt: now.Add(-1 * time.Hour),
|
||||
}
|
||||
fresh := &MagicLinkToken{
|
||||
Email: "fresh@example.com",
|
||||
TokenHash: HashMagicLinkToken("fresh-token"),
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
require.NoError(t, repo.CreateMagicLinkToken(ctx, expired))
|
||||
require.NoError(t, repo.CreateMagicLinkToken(ctx, fresh))
|
||||
|
||||
deleted, err := repo.DeleteExpiredMagicLinkTokens(ctx, now)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, deleted, "exactly one row was past the cutoff")
|
||||
|
||||
// Expired row is gone, fresh row is still there.
|
||||
got, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("expired-token"))
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got, "expired token must be gone")
|
||||
|
||||
got, err = repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("fresh-token"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got, "fresh token must remain")
|
||||
}
|
||||
|
||||
// TestMagicLinkRepo_HashUniqueness is a defensive check that the unique
|
||||
// index on token_hash actually rejects duplicates. If the index is ever
|
||||
// dropped from the schema, this test catches it before security does.
|
||||
func TestMagicLinkRepo_HashUniqueness(t *testing.T) {
|
||||
repo := freshRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, hashHex, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
first := &MagicLinkToken{
|
||||
Email: "a@example.com",
|
||||
TokenHash: hashHex,
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
require.NoError(t, repo.CreateMagicLinkToken(ctx, first))
|
||||
|
||||
dup := &MagicLinkToken{
|
||||
Email: "b@example.com",
|
||||
TokenHash: hashHex, // same hash as `first`
|
||||
ExpiresAt: time.Now().Add(15 * time.Minute),
|
||||
}
|
||||
err = repo.CreateMagicLinkToken(ctx, dup)
|
||||
require.Error(t, err, "second insert with same hash must violate the unique index")
|
||||
}
|
||||
78
pkg/user/magic_link_test.go
Normal file
78
pkg/user/magic_link_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGenerateMagicLinkToken_ShapeAndHashAgree confirms the contract that
|
||||
// HashMagicLinkToken(plaintext) == returned hashHex. Without that, the
|
||||
// consume handler can never look up what request stored.
|
||||
func TestGenerateMagicLinkToken_ShapeAndHashAgree(t *testing.T) {
|
||||
plain, hashHex, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, plain)
|
||||
assert.NotEmpty(t, hashHex)
|
||||
assert.Len(t, hashHex, 64, "sha256 hex = 64 chars")
|
||||
assert.Equal(t, hashHex, HashMagicLinkToken(plain),
|
||||
"GenerateMagicLinkToken must return a hash that matches HashMagicLinkToken(plain)")
|
||||
}
|
||||
|
||||
// TestGenerateMagicLinkToken_PlainIsURLSafeBase64 confirms the link can
|
||||
// be embedded in a URL without further escaping. RawURLEncoding => no
|
||||
// "/", "+", or "=" padding chars.
|
||||
func TestGenerateMagicLinkToken_PlainIsURLSafeBase64(t *testing.T) {
|
||||
plain, _, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, bad := range []string{"/", "+", "="} {
|
||||
assert.False(t, strings.Contains(plain, bad),
|
||||
"plaintext token must not contain %q (URL-unsafe)", bad)
|
||||
}
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(plain)
|
||||
require.NoError(t, err, "plaintext must round-trip through RawURLEncoding")
|
||||
assert.Len(t, decoded, 32, "32 bytes of entropy")
|
||||
}
|
||||
|
||||
// TestGenerateMagicLinkToken_Unique confirms two consecutive calls
|
||||
// produce different tokens (not a deterministic seeding bug).
|
||||
func TestGenerateMagicLinkToken_Unique(t *testing.T) {
|
||||
a, ah, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
b, bh, err := GenerateMagicLinkToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, a, b, "plaintexts must differ between calls")
|
||||
assert.NotEqual(t, ah, bh, "hashes must differ between calls")
|
||||
}
|
||||
|
||||
// TestHashMagicLinkToken_StableAndCorrect confirms HashMagicLinkToken is
|
||||
// a pure function (same input -> same output) AND that it produces the
|
||||
// expected sha256 hex digest. Cross-checked against the stdlib so we
|
||||
// catch any accidental algorithm swap.
|
||||
func TestHashMagicLinkToken_StableAndCorrect(t *testing.T) {
|
||||
const sample = "abc123-test-token"
|
||||
got1 := HashMagicLinkToken(sample)
|
||||
got2 := HashMagicLinkToken(sample)
|
||||
assert.Equal(t, got1, got2, "HashMagicLinkToken must be deterministic")
|
||||
|
||||
sum := sha256.Sum256([]byte(sample))
|
||||
want := hex.EncodeToString(sum[:])
|
||||
assert.Equal(t, want, got1, "HashMagicLinkToken must be sha256 hex")
|
||||
}
|
||||
|
||||
// TestHashMagicLinkToken_DiffersOnDifferentInput is the tautological
|
||||
// counter-test of stability : different inputs -> different outputs.
|
||||
// Catches the (unlikely) case where someone replaces the impl with
|
||||
// a constant.
|
||||
func TestHashMagicLinkToken_DiffersOnDifferentInput(t *testing.T) {
|
||||
assert.NotEqual(t, HashMagicLinkToken("a"), HashMagicLinkToken("b"))
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func NewPostgresRepositoryFromDSN(cfg *config.Config, dsn string) (*PostgresRepo
|
||||
sqlDB.SetMaxIdleConns(cfg.GetDatabaseMaxIdleConns())
|
||||
sqlDB.SetConnMaxLifetime(cfg.GetDatabaseConnMaxLifetime())
|
||||
|
||||
if err := db.AutoMigrate(&User{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-migrate via custom DSN: %w", err)
|
||||
}
|
||||
|
||||
@@ -264,8 +264,8 @@ func (r *PostgresRepository) initializeDatabase() error {
|
||||
sqlDB.SetMaxIdleConns(r.config.GetDatabaseMaxIdleConns())
|
||||
sqlDB.SetConnMaxLifetime(r.config.GetDatabaseConnMaxLifetime())
|
||||
|
||||
// Auto-migrate the User model
|
||||
if err := r.db.AutoMigrate(&User{}); err != nil {
|
||||
// Auto-migrate the User model + MagicLinkToken (ADR-0028 Phase A)
|
||||
if err := r.db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
|
||||
return fmt.Errorf("failed to auto-migrate: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user