feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3) (#61)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m11s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-05 11:24:06 +02:00
committed by arcodange
parent b3027d2669
commit c9ab876dfe
4 changed files with 425 additions and 3 deletions

150
pkg/user/magic_link.go Normal file
View 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
}