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 }