Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
151 lines
5.3 KiB
Go
151 lines
5.3 KiB
Go
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
|
|
}
|