From edad4b2fe7344d60a6c4ce0816138e0c586beffc Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 6 Jun 2026 15:52:22 +0100 Subject: [PATCH 1/7] feat(account): OTP code primitives for email verification (TDD) Add the foundation for 6-digit OTP email verification: - GenerateOTPCode(): crypto-random zero-padded 6-digit code. - Verification.Attempts: track failed OTP entries (enforce a max before resend). - NewEmailVerificationCode(): constructor producing an email verification record whose Token holds the OTP code. Covered by account/otp_test.go. Subsequent commits add the store lookup, engine issue/verify, API, and notification wiring. Co-Authored-By: Claude Opus 4.8 --- account/account.go | 6 +++++ account/otp_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++ account/service.go | 32 +++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 account/otp_test.go diff --git a/account/account.go b/account/account.go index 2975dffe..aa636c9d 100644 --- a/account/account.go +++ b/account/account.go @@ -8,6 +8,11 @@ import ( ) // Verification represents an email or phone verification token. +// +// Token holds either a long random token (link-based flows like magic link) or +// a short 6-digit OTP code (email verification). For OTP flows, Attempts tracks +// failed entry attempts so callers can enforce a maximum before requiring a +// resend. type Verification struct { ID id.VerificationID `json:"id"` AppID id.AppID `json:"app_id"` @@ -15,6 +20,7 @@ type Verification struct { UserID id.UserID `json:"user_id"` Token string `json:"-"` Type VerificationType `json:"type"` + Attempts int `json:"attempts"` ExpiresAt time.Time `json:"expires_at"` Consumed bool `json:"consumed"` CreatedAt time.Time `json:"created_at"` diff --git a/account/otp_test.go b/account/otp_test.go new file mode 100644 index 00000000..a85754ec --- /dev/null +++ b/account/otp_test.go @@ -0,0 +1,62 @@ +package account + +import ( + "testing" + "time" + + "github.com/xraph/authsome/id" +) + +func TestGenerateOTPCode(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 200; i++ { + code, err := GenerateOTPCode() + if err != nil { + t.Fatalf("GenerateOTPCode error: %v", err) + } + if len(code) != 6 { + t.Fatalf("expected a 6-digit code, got %q (len %d)", code, len(code)) + } + for _, r := range code { + if r < '0' || r > '9' { + t.Fatalf("code %q contains a non-digit", code) + } + } + seen[code] = true + } + // 200 draws from 1e6 should almost never collide enough to drop below 100 uniques. + if len(seen) < 100 { + t.Fatalf("codes not sufficiently random: %d unique of 200", len(seen)) + } +} + +func TestNewEmailVerificationCode(t *testing.T) { + appID := id.NewAppID() + userID := id.NewUserID() + + v, err := NewEmailVerificationCode(appID, userID, 15*time.Minute) + if err != nil { + t.Fatalf("NewEmailVerificationCode error: %v", err) + } + if v.Type != VerificationEmail { + t.Fatalf("expected type %q, got %q", VerificationEmail, v.Type) + } + if v.AppID != appID || v.UserID != userID { + t.Fatalf("app/user id mismatch") + } + if len(v.Token) != 6 { + t.Fatalf("expected a 6-digit code in Token, got %q", v.Token) + } + if v.Consumed { + t.Fatalf("new verification should not be consumed") + } + if v.Attempts != 0 { + t.Fatalf("new verification should have 0 attempts, got %d", v.Attempts) + } + if !v.ExpiresAt.After(time.Now()) { + t.Fatalf("verification should expire in the future") + } + if v.ID.IsNil() { + t.Fatalf("verification should have an ID") + } +} diff --git a/account/service.go b/account/service.go index 5df6ffd8..9206f145 100644 --- a/account/service.go +++ b/account/service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "math/big" "strings" "time" "unicode" @@ -256,6 +257,37 @@ func GenerateVerificationToken() (string, error) { return generateSecureToken(32) } +// GenerateOTPCode returns a cryptographically random 6-digit numeric code, +// zero-padded (e.g. "004271"). Used for email/phone OTP verification where the +// user types a short code instead of clicking a long token link. +func GenerateOTPCode() (string, error) { + n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} + +// NewEmailVerificationCode creates an email-verification record carrying a +// 6-digit OTP code (stored in Token) that the user types to verify. Attempts +// starts at 0. +func NewEmailVerificationCode(appID id.AppID, userID id.UserID, ttl time.Duration) (*Verification, error) { + code, err := GenerateOTPCode() + if err != nil { + return nil, err + } + now := time.Now() + return &Verification{ + ID: id.NewVerificationID(), + AppID: appID, + UserID: userID, + Token: code, + Type: VerificationEmail, + ExpiresAt: now.Add(ttl), + CreatedAt: now, + }, nil +} + // NewVerification creates a new email/phone verification record. func NewVerification(_ context.Context, appID id.AppID, userID id.UserID, vType VerificationType, ttl time.Duration) (*Verification, error) { token, err := GenerateVerificationToken() From c87530631713616f2439f0da398ff09b68667164 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 6 Jun 2026 17:11:47 +0100 Subject: [PATCH 2/7] feat(verification): OTP email verification engine + store (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the missing email-verification flow end-to-end on the engine/store side: - Engine: IssueEmailVerification (mint 6-digit OTP, store it, fire ActionEmailVerificationRequested carrying the code) and VerifyEmailCode (per-user lookup, expiry, max-attempts, constant-time compare → set EmailVerified). SignUp now issues a code for unverified users. Adds ErrTooManyAttempts. - Store: GetActiveEmailVerification(userID) + UpdateVerification on the interface and all four backends (memory/postgres/sqlite/mongo), plus an `attempts` column and a non-unique token index (6-digit codes are not globally unique) with a per-user active-code index. Covered by service_otp_test.go (issue, wrong→right, max-attempts) and account/otp_test.go. Co-Authored-By: Claude Opus 4.8 --- account/service.go | 1 + account/store.go | 15 ++++- service.go | 117 +++++++++++++++++++++++++++++++++++ service_otp_test.go | 102 ++++++++++++++++++++++++++++++ store/memory/store.go | 32 ++++++++++ store/mongo/account.go | 39 ++++++++++++ store/mongo/models.go | 3 + store/postgres/migrations.go | 29 +++++++++ store/postgres/models.go | 3 + store/postgres/store.go | 25 ++++++++ store/sqlite/migrations.go | 23 +++++++ store/sqlite/models.go | 3 + store/sqlite/store.go | 25 ++++++++ 13 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 service_otp_test.go diff --git a/account/service.go b/account/service.go index 9206f145..518b4734 100644 --- a/account/service.go +++ b/account/service.go @@ -30,6 +30,7 @@ var ( ErrPasswordExpired = errors.New("account: password has expired and must be changed") ErrPasswordReused = errors.New("account: password was recently used and cannot be reused") ErrEmailNotVerified = errors.New("account: email address must be verified before signing in") + ErrTooManyAttempts = errors.New("account: too many verification attempts; request a new code") ErrMFARequired = errors.New("account: MFA challenge required to complete sign-in") ) diff --git a/account/store.go b/account/store.go index 99a38e4b..3bafe87d 100644 --- a/account/store.go +++ b/account/store.go @@ -1,6 +1,10 @@ package account -import "context" +import ( + "context" + + "github.com/xraph/authsome/id" +) // Store defines the persistence interface for account lifecycle operations. type Store interface { @@ -8,6 +12,15 @@ type Store interface { GetVerification(ctx context.Context, token string) (*Verification, error) ConsumeVerification(ctx context.Context, token string) error + // GetActiveEmailVerification returns the most recent unconsumed, unexpired + // email verification for the user, or ErrNotFound. The OTP flow looks up by + // user (codes are short and not globally unique) rather than by token. + GetActiveEmailVerification(ctx context.Context, userID id.UserID) (*Verification, error) + + // UpdateVerification persists mutable fields of an existing verification + // (Attempts, Consumed). + UpdateVerification(ctx context.Context, v *Verification) error + CreatePasswordReset(ctx context.Context, pr *PasswordReset) error GetPasswordReset(ctx context.Context, token string) (*PasswordReset, error) ConsumePasswordReset(ctx context.Context, token string) error diff --git a/service.go b/service.go index 85f1080d..80b10b8a 100644 --- a/service.go +++ b/service.go @@ -11,6 +11,7 @@ import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "github.com/xraph/forge" @@ -152,6 +153,14 @@ func (e *Engine) SignUp(ctx context.Context, req *account.SignUpRequest) (*user. // Plugin: after user create e.plugins.EmitAfterUserCreate(ctx, u) + // Issue an email-verification OTP for the new (unverified) user. Best-effort: + // a failure to mint/deliver the code must not block signup. + if !u.EmailVerified { + if issueErr := e.issueEmailVerificationForUser(ctx, u); issueErr != nil { + e.logger.Warn("authsome: issue email verification failed", log.String("error", issueErr.Error())) + } + } + // Assign default Warden role to the new user. e.EnsureDefaultRole(ctx, req.AppID, u.ID) @@ -1220,6 +1229,114 @@ func (e *Engine) VerifyEmail(ctx context.Context, token string) error { return nil } +// emailVerificationTTL is how long an email OTP code stays valid. +const emailVerificationTTL = 15 * time.Minute + +// maxEmailVerificationAttempts caps wrong-code entries before a fresh code must +// be requested (mitigates brute-forcing the 6-digit code). +const maxEmailVerificationAttempts = 5 + +// IssueEmailVerification mints a fresh 6-digit OTP for the user's email and +// fires ActionEmailVerificationRequested carrying the code so the notification +// layer can deliver it. Safe to call repeatedly (e.g. resend). +func (e *Engine) IssueEmailVerification(ctx context.Context, userID id.UserID) error { + if err := e.requireStarted(); err != nil { + return err + } + u, err := e.store.GetUser(ctx, userID) + if err != nil { + return fmt.Errorf("authsome: get user: %w", err) + } + return e.issueEmailVerificationForUser(ctx, u) +} + +// issueEmailVerificationForUser is the internal path used by both +// IssueEmailVerification and SignUp (which already has the user loaded). +func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User) error { + v, err := account.NewEmailVerificationCode(u.AppID, u.ID, emailVerificationTTL) + if err != nil { + return fmt.Errorf("authsome: new email verification: %w", err) + } + if createErr := e.store.CreateVerification(ctx, v); createErr != nil { + return fmt.Errorf("authsome: create verification: %w", createErr) + } + + e.hooks.Emit(ctx, &hook.Event{ + Action: hook.ActionEmailVerificationRequested, + Resource: hook.ResourceUser, + ResourceID: u.ID.String(), + ActorID: u.ID.String(), + Tenant: u.AppID.String(), + Metadata: map[string]string{ + "email": u.Email, + "user_name": u.Name(), + "code": v.Token, + }, + }) + return nil +} + +// VerifyEmailCode verifies a user's email using the 6-digit OTP code they were +// emailed. It enforces expiry and a maximum number of attempts, and uses a +// constant-time comparison to avoid leaking the code via timing. +func (e *Engine) VerifyEmailCode(ctx context.Context, userID id.UserID, code string) error { + if err := e.requireStarted(); err != nil { + return err + } + + v, err := e.store.GetActiveEmailVerification(ctx, userID) + if err != nil { + return account.ErrInvalidCredentials + } + if v.Consumed || time.Now().After(v.ExpiresAt) { + return account.ErrInvalidCredentials + } + if v.Attempts >= maxEmailVerificationAttempts { + return account.ErrTooManyAttempts + } + + if subtle.ConstantTimeCompare([]byte(v.Token), []byte(code)) != 1 { + v.Attempts++ + _ = e.store.UpdateVerification(ctx, v) //nolint:errcheck // best-effort attempt tracking + return account.ErrInvalidCredentials + } + + v.Consumed = true + if updateErr := e.store.UpdateVerification(ctx, v); updateErr != nil { + return fmt.Errorf("authsome: consume verification: %w", updateErr) + } + + u, err := e.store.GetUser(ctx, userID) + if err != nil { + return fmt.Errorf("authsome: get user: %w", err) + } + u.EmailVerified = true + u.UpdatedAt = time.Now() + if updateErr := e.store.UpdateUser(ctx, u); updateErr != nil { + return fmt.Errorf("authsome: update user: %w", updateErr) + } + + e.audit(ctx, bridge.SeverityInfo, bridge.OutcomeSuccess, "verify_email", "user", u.ID.String(), u.ID.String(), u.AppID.String(), "auth", nil) + + e.hooks.Emit(ctx, &hook.Event{ + Action: hook.ActionEmailVerify, + Resource: hook.ResourceUser, + ResourceID: u.ID.String(), + ActorID: u.ID.String(), + Tenant: u.AppID.String(), + Metadata: map[string]string{ + "email": u.Email, + "user_name": u.Name(), + }, + }) + + e.relayEvent(ctx, "user.email_verified", u.AppID.String(), map[string]string{ + "user_id": u.ID.String(), + }) + + return nil +} + // ────────────────────────────────────────────────── // Device Management // ────────────────────────────────────────────────── diff --git a/service_otp_test.go b/service_otp_test.go new file mode 100644 index 00000000..9e0cc8d9 --- /dev/null +++ b/service_otp_test.go @@ -0,0 +1,102 @@ +package authsome_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/account" +) + +func wrongCode(code string) string { + if code == "000000" { + return "111111" + } + return "000000" +} + +// SignUp should mint an email-verification OTP for the new (unverified) user. +func TestSignUp_IssuesEmailVerificationCode(t *testing.T) { + eng, st := newTestEngine(t) + ctx := context.Background() + appID := testAppID(t) + + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "bob@example.com", + Password: "SecureP@ss1", + Username: "bob", + }) + require.NoError(t, err) + + v, err := st.GetActiveEmailVerification(ctx, u.ID) + require.NoError(t, err) + assert.Len(t, v.Token, 6, "verification should carry a 6-digit code") + assert.Equal(t, account.VerificationEmail, v.Type) + assert.Equal(t, 0, v.Attempts) +} + +func TestVerifyEmailCode_WrongThenRight(t *testing.T) { + eng, st := newTestEngine(t) + ctx := context.Background() + appID := testAppID(t) + + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "carol@example.com", + Password: "SecureP@ss1", + Username: "carol", + }) + require.NoError(t, err) + + v, err := st.GetActiveEmailVerification(ctx, u.ID) + require.NoError(t, err) + code := v.Token + + // Wrong code fails and increments attempts. + err = eng.VerifyEmailCode(ctx, u.ID, wrongCode(code)) + require.ErrorIs(t, err, account.ErrInvalidCredentials) + + v2, err := st.GetActiveEmailVerification(ctx, u.ID) + require.NoError(t, err) + assert.Equal(t, 1, v2.Attempts, "failed attempt should be recorded") + + // Correct code verifies the email and consumes the code. + require.NoError(t, eng.VerifyEmailCode(ctx, u.ID, code)) + + gu, err := st.GetUser(ctx, u.ID) + require.NoError(t, err) + assert.True(t, gu.EmailVerified) + + _, err = st.GetActiveEmailVerification(ctx, u.ID) + require.Error(t, err, "code should be consumed after success") +} + +func TestVerifyEmailCode_MaxAttempts(t *testing.T) { + eng, st := newTestEngine(t) + ctx := context.Background() + appID := testAppID(t) + + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "dave@example.com", + Password: "SecureP@ss1", + Username: "dave", + }) + require.NoError(t, err) + + v, err := st.GetActiveEmailVerification(ctx, u.ID) + require.NoError(t, err) + code := v.Token + wrong := wrongCode(code) + + // Exhaust the attempt budget with wrong codes. + for i := 0; i < 5; i++ { + require.ErrorIs(t, eng.VerifyEmailCode(ctx, u.ID, wrong), account.ErrInvalidCredentials) + } + + // Even the correct code is now rejected (locked out until resend). + require.ErrorIs(t, eng.VerifyEmailCode(ctx, u.ID, code), account.ErrTooManyAttempts) +} diff --git a/store/memory/store.go b/store/memory/store.go index b1389caa..d8f093da 100644 --- a/store/memory/store.go +++ b/store/memory/store.go @@ -579,6 +579,38 @@ func (s *Store) ConsumeVerification(_ context.Context, token string) error { return nil } +func (s *Store) GetActiveEmailVerification(_ context.Context, userID id.UserID) (*account.Verification, error) { + s.mu.RLock() + defer s.mu.RUnlock() + now := time.Now() + var latest *account.Verification + for _, v := range s.verifications { + if v.UserID != userID || v.Type != account.VerificationEmail { + continue + } + if v.Consumed || now.After(v.ExpiresAt) { + continue + } + if latest == nil || v.CreatedAt.After(latest.CreatedAt) { + latest = v + } + } + if latest == nil { + return nil, store.ErrNotFound + } + return latest, nil +} + +func (s *Store) UpdateVerification(_ context.Context, v *account.Verification) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.verifications[v.Token]; !ok { + return store.ErrNotFound + } + s.verifications[v.Token] = v + return nil +} + func (s *Store) CreatePasswordReset(_ context.Context, pr *account.PasswordReset) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/store/mongo/account.go b/store/mongo/account.go index 42f06414..4f645d29 100644 --- a/store/mongo/account.go +++ b/store/mongo/account.go @@ -3,10 +3,12 @@ package mongo import ( "context" "fmt" + "time" "go.mongodb.org/mongo-driver/v2/bson" "github.com/xraph/authsome/account" + "github.com/xraph/authsome/id" "github.com/xraph/authsome/store" ) @@ -61,6 +63,43 @@ func (s *Store) ConsumeVerification(ctx context.Context, token string) error { return nil } +// GetActiveEmailVerification returns the most recent unconsumed, unexpired +// email verification for a user (OTP lookup by user, not token). +func (s *Store) GetActiveEmailVerification(ctx context.Context, userID id.UserID) (*account.Verification, error) { + var m verificationModel + + err := s.mdb.NewFind(&m). + Filter(bson.M{ + "user_id": userID.String(), + "type": string(account.VerificationEmail), + "consumed": false, + "expires_at": bson.M{"$gt": time.Now()}, + }). + Sort(bson.D{{Key: "created_at", Value: -1}}). + Scan(ctx) + if err != nil { + if isNoDocuments(err) { + return nil, store.ErrNotFound + } + return nil, fmt.Errorf("authsome/mongo: get active email verification: %w", err) + } + + return fromVerificationModel(&m) +} + +// UpdateVerification persists mutable fields (attempts, consumed). +func (s *Store) UpdateVerification(ctx context.Context, v *account.Verification) error { + _, err := s.mdb.NewUpdate((*verificationModel)(nil)). + Filter(bson.M{"_id": v.ID.String()}). + Set("attempts", v.Attempts). + Set("consumed", v.Consumed). + Exec(ctx) + if err != nil { + return fmt.Errorf("authsome/mongo: update verification: %w", err) + } + return nil +} + // ────────────────────────────────────────────────── // Password Reset // ────────────────────────────────────────────────── diff --git a/store/mongo/models.go b/store/mongo/models.go index f6a985ac..75c85f5d 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -303,6 +303,7 @@ type verificationModel struct { UserID string `grove:"user_id" bson:"user_id"` Token string `grove:"token" bson:"token"` Type string `grove:"type" bson:"type"` + Attempts int `grove:"attempts" bson:"attempts"` ExpiresAt time.Time `grove:"expires_at" bson:"expires_at"` Consumed bool `grove:"consumed" bson:"consumed"` CreatedAt time.Time `grove:"created_at" bson:"created_at"` @@ -316,6 +317,7 @@ func toVerificationModel(v *account.Verification) *verificationModel { UserID: v.UserID.String(), Token: v.Token, Type: string(v.Type), + Attempts: v.Attempts, ExpiresAt: v.ExpiresAt, Consumed: v.Consumed, CreatedAt: v.CreatedAt, @@ -343,6 +345,7 @@ func fromVerificationModel(m *verificationModel) (*account.Verification, error) UserID: userID, Token: m.Token, Type: account.VerificationType(m.Type), + Attempts: m.Attempts, ExpiresAt: m.ExpiresAt, Consumed: m.Consumed, CreatedAt: m.CreatedAt, diff --git a/store/postgres/migrations.go b/store/postgres/migrations.go index 7b732df8..3a39f4fa 100644 --- a/store/postgres/migrations.go +++ b/store/postgres/migrations.go @@ -1035,5 +1035,34 @@ CREATE INDEX IF NOT EXISTS idx_authsome_user_emails_user return err }, }, + + // Migration: email-verification OTP support. + // Adds an attempts counter and replaces the UNIQUE token index with a + // non-unique one (6-digit OTP codes are not globally unique across + // users), plus a per-user lookup index for the active-code query. + &migrate.Migration{ + Name: "verification_otp_support", + Version: "20260605000001", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +ALTER TABLE authsome_verifications + ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0; + +DROP INDEX IF EXISTS idx_authsome_verifications_token; +CREATE INDEX IF NOT EXISTS idx_authsome_verifications_token + ON authsome_verifications (token); +CREATE INDEX IF NOT EXISTS idx_authsome_verifications_active + ON authsome_verifications (user_id, type, consumed, expires_at); +`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +DROP INDEX IF EXISTS idx_authsome_verifications_active; +ALTER TABLE authsome_verifications DROP COLUMN IF EXISTS attempts; +`) + return err + }, + }, ) } diff --git a/store/postgres/models.go b/store/postgres/models.go index 19cc683b..62cfe738 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -319,6 +319,7 @@ type VerificationModel struct { UserID string `grove:"user_id,notnull"` Token string `grove:"token,notnull"` Type string `grove:"type,notnull"` + Attempts int `grove:"attempts,notnull,default:0"` ExpiresAt time.Time `grove:"expires_at,notnull"` Consumed bool `grove:"consumed"` CreatedAt time.Time `grove:"created_at,notnull,default:now()"` @@ -348,6 +349,7 @@ func toVerification(m *VerificationModel) (*account.Verification, error) { UserID: userID, Token: m.Token, Type: account.VerificationType(m.Type), + Attempts: m.Attempts, ExpiresAt: m.ExpiresAt, Consumed: m.Consumed, CreatedAt: m.CreatedAt, @@ -362,6 +364,7 @@ func fromVerification(v *account.Verification) *VerificationModel { UserID: v.UserID.String(), Token: v.Token, Type: string(v.Type), + Attempts: v.Attempts, ExpiresAt: v.ExpiresAt, Consumed: v.Consumed, CreatedAt: v.CreatedAt, diff --git a/store/postgres/store.go b/store/postgres/store.go index 17cf9df3..084728fe 100644 --- a/store/postgres/store.go +++ b/store/postgres/store.go @@ -421,6 +421,31 @@ func (s *Store) ConsumeVerification(ctx context.Context, token string) error { return pgError(err) } +func (s *Store) GetActiveEmailVerification(ctx context.Context, userID id.UserID) (*account.Verification, error) { + m := new(VerificationModel) + err := s.pg.NewSelect(m). + Where("user_id = ?", userID.String()). + Where("type = ?", string(account.VerificationEmail)). + Where("consumed = FALSE"). + Where("expires_at > ?", time.Now()). + OrderExpr("created_at DESC"). + Limit(1). + Scan(ctx) + if err != nil { + return nil, pgError(err) + } + return toVerification(m) +} + +func (s *Store) UpdateVerification(ctx context.Context, v *account.Verification) error { + _, err := s.pg.NewUpdate((*VerificationModel)(nil)). + Set("attempts = ?", v.Attempts). + Set("consumed = ?", v.Consumed). + Where("id = ?", v.ID.String()). + Exec(ctx) + return pgError(err) +} + func (s *Store) CreatePasswordReset(ctx context.Context, pr *account.PasswordReset) error { m := fromPasswordReset(pr) _, err := s.pg.NewInsert(m).Exec(ctx) diff --git a/store/sqlite/migrations.go b/store/sqlite/migrations.go index c3f6155d..4aa06195 100644 --- a/store/sqlite/migrations.go +++ b/store/sqlite/migrations.go @@ -931,5 +931,28 @@ CREATE INDEX IF NOT EXISTS idx_authsome_user_emails_user return err }, }, + + // Migration: email-verification OTP support (attempts counter + + // non-unique token index, since 6-digit OTP codes are not globally + // unique across users). + &migrate.Migration{ + Name: "verification_otp_support", + Version: "20260605000001", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +ALTER TABLE authsome_verifications ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0; +DROP INDEX IF EXISTS idx_authsome_verifications_token; +CREATE INDEX IF NOT EXISTS idx_authsome_verifications_token + ON authsome_verifications (token); +CREATE INDEX IF NOT EXISTS idx_authsome_verifications_active + ON authsome_verifications (user_id, type, consumed, expires_at); +`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `DROP INDEX IF EXISTS idx_authsome_verifications_active;`) + return err + }, + }, ) } diff --git a/store/sqlite/models.go b/store/sqlite/models.go index 3604ea64..3b86a51c 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -319,6 +319,7 @@ type VerificationModel struct { UserID string `grove:"user_id,notnull"` Token string `grove:"token,notnull"` Type string `grove:"type,notnull"` + Attempts int `grove:"attempts,notnull,default:0"` ExpiresAt time.Time `grove:"expires_at,notnull"` Consumed bool `grove:"consumed"` CreatedAt time.Time `grove:"created_at,notnull,default:now()"` @@ -348,6 +349,7 @@ func toVerification(m *VerificationModel) (*account.Verification, error) { UserID: userID, Token: m.Token, Type: account.VerificationType(m.Type), + Attempts: m.Attempts, ExpiresAt: m.ExpiresAt, Consumed: m.Consumed, CreatedAt: m.CreatedAt, @@ -362,6 +364,7 @@ func fromVerification(v *account.Verification) *VerificationModel { UserID: v.UserID.String(), Token: v.Token, Type: string(v.Type), + Attempts: v.Attempts, ExpiresAt: v.ExpiresAt, Consumed: v.Consumed, CreatedAt: v.CreatedAt, diff --git a/store/sqlite/store.go b/store/sqlite/store.go index e00b98f0..d9eee26b 100644 --- a/store/sqlite/store.go +++ b/store/sqlite/store.go @@ -421,6 +421,31 @@ func (s *Store) ConsumeVerification(ctx context.Context, token string) error { return sqliteError(err) } +func (s *Store) GetActiveEmailVerification(ctx context.Context, userID id.UserID) (*account.Verification, error) { + m := new(VerificationModel) + err := s.sdb.NewSelect(m). + Where("user_id = ?", userID.String()). + Where("type = ?", string(account.VerificationEmail)). + Where("consumed = FALSE"). + Where("expires_at > ?", time.Now()). + OrderExpr("created_at DESC"). + Limit(1). + Scan(ctx) + if err != nil { + return nil, sqliteError(err) + } + return toVerification(m) +} + +func (s *Store) UpdateVerification(ctx context.Context, v *account.Verification) error { + _, err := s.sdb.NewUpdate((*VerificationModel)(nil)). + Set("attempts = ?", v.Attempts). + Set("consumed = ?", v.Consumed). + Where("id = ?", v.ID.String()). + Exec(ctx) + return sqliteError(err) +} + func (s *Store) CreatePasswordReset(ctx context.Context, pr *account.PasswordReset) error { m := fromPasswordReset(pr) _, err := s.sdb.NewInsert(m).Exec(ctx) From cbb987a92d401c079b81a49901c40029ada1dc3a Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 6 Jun 2026 17:29:24 +0100 Subject: [PATCH 3/7] feat(verification): OTP API + notification wiring; single issue path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: POST /v1/verify-email now accepts {code} (verifies the authenticated session user's email via VerifyEmailCode) in addition to legacy {token}; ErrTooManyAttempts maps to HTTP 429. - Engine: SendEmailVerification + SignUp + resend all delegate to one OTP issue path (issueEmailVerificationForUser), so the hook carries `code` everywhere. - Notification: OnAfterUserCreate is now a no-op — the engine owns issuance and fires ActionEmailVerificationRequested (carrying the code), delivered by handleHookEvent. Stops the previous duplicate, tokenless email. - Update the resend hook test to assert the 6-digit code. Full backend suite (account/api/memory/plugins/root) green. Co-Authored-By: Claude Opus 4.8 --- api/api_test.go | 2 +- api/helpers.go | 3 ++ api/password_handlers.go | 24 ++++++++++++---- api/requests.go | 8 +++++- plugins/notification/plugin.go | 47 +++++--------------------------- service.go | 50 ++++++++++++++-------------------- 6 files changed, 57 insertions(+), 77 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index deccbb20..edafb543 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1200,7 +1200,7 @@ func TestResendVerification_CreatesTokenForExistingUnverifiedUser(t *testing.T) require.Equal(t, http.StatusOK, rec.Code) require.NotNil(t, captured, "auth.email_verification_requested hook must fire for a real unverified user") - require.NotEmpty(t, captured["verification_token"], "hook payload must carry the token so a delivery handler can render the link") + require.Len(t, captured["code"], 6, "hook payload must carry the 6-digit OTP code for the delivery handler to render") require.NotEmpty(t, captured["expires_at"]) require.Equal(t, "resend-target@example.com", captured["email"]) } diff --git a/api/helpers.go b/api/helpers.go index 10218e84..22fbede1 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -74,6 +74,9 @@ func mapError(err error) error { if errors.Is(err, account.ErrInvalidCredentials) { return forge.Unauthorized("invalid credentials") } + if errors.Is(err, account.ErrTooManyAttempts) { + return forge.NewHTTPError(http.StatusTooManyRequests, "too many verification attempts; request a new code") + } if errors.Is(err, account.ErrEmailTaken) { return forge.NewHTTPError(http.StatusConflict, "email already taken") } diff --git a/api/password_handlers.go b/api/password_handlers.go index 9f3c1529..fd634125 100644 --- a/api/password_handlers.go +++ b/api/password_handlers.go @@ -127,16 +127,28 @@ func (a *API) handleChangePassword(ctx forge.Context, req *ChangePasswordRequest } func (a *API) handleVerifyEmail(ctx forge.Context, req *VerifyEmailRequest) (*StatusResponse, error) { - if req.Token == "" { - return nil, forge.BadRequest("token is required") + // Code mode (OTP): verify the authenticated session user's email. The user + // is authenticated via the session minted at signup. + if req.Code != "" { + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok { + return nil, forge.Unauthorized("authentication required to verify with a code") + } + if err := a.engine.VerifyEmailCode(ctx.Context(), userID, req.Code); err != nil { + return nil, mapError(err) + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) } - if err := a.engine.VerifyEmail(ctx.Context(), req.Token); err != nil { - return nil, mapError(err) + // Token mode (link-based flows, e.g. magic link). + if req.Token != "" { + if err := a.engine.VerifyEmail(ctx.Context(), req.Token); err != nil { + return nil, mapError(err) + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) } - resp := &StatusResponse{Status: "email verified"} - return nil, ctx.JSON(http.StatusOK, resp) + return nil, forge.BadRequest("code or token is required") } func (a *API) handleResendVerification(ctx forge.Context, req *ResendVerificationRequest) (*StatusResponse, error) { diff --git a/api/requests.go b/api/requests.go index cdd27d3b..82036b31 100644 --- a/api/requests.go +++ b/api/requests.go @@ -58,8 +58,14 @@ type ChangePasswordRequest struct { } // VerifyEmailRequest binds the body for POST /verify-email. +// +// Two modes are supported: +// - Code: the 6-digit OTP emailed to the user. Verifies the email of the +// authenticated session user (the account just created at signup). +// - Token: a long verification token (link-based flows, e.g. magic link). type VerifyEmailRequest struct { - Token string `json:"token" description:"Email verification token"` + Code string `json:"code,omitempty" description:"6-digit OTP code (verifies the authenticated user's email)"` + Token string `json:"token,omitempty" description:"Email verification token (link-based flows)"` } // ResendVerificationRequest binds the body for POST /verify-email/resend. diff --git a/plugins/notification/plugin.go b/plugins/notification/plugin.go index ab7df42e..7298ba10 100644 --- a/plugins/notification/plugin.go +++ b/plugins/notification/plugin.go @@ -237,46 +237,13 @@ func (p *Plugin) OnAfterSignUp(ctx context.Context, u *user.User, _ *session.Ses return nil } -// OnAfterUserCreate sends a verification notification to the newly created user. -func (p *Plugin) OnAfterUserCreate(ctx context.Context, u *user.User) error { - if p.herald == nil { - return nil - } - - // Only send if user has not yet verified their email. - if u.EmailVerified { - return nil - } - - m, ok := p.mappings[hook.ActionUserCreate] - if !ok || !m.Enabled { - return nil - } - - name := u.Name() - if name == "" { - name = u.Email - } - - if err := p.herald.Notify(ctx, &bridge.HeraldNotifyRequest{ - Template: m.Template, - Channels: m.Channels, - To: []string{u.Email}, - UserID: u.ID.String(), - Locale: p.config.DefaultLocale, - Async: p.config.Async, - Data: map[string]any{ - "user_name": name, - "app_name": p.config.AppName, - "verify_url": p.config.BaseURL + "/verify-email", - }, - }); err != nil { - p.logger.Warn("notification plugin: failed to send verification notification", - log.String("email", u.Email), - log.String("error", err.Error()), - ) - } - +// OnAfterUserCreate is intentionally a no-op for email verification. +// +// The engine now owns verification issuance: SignUp (and ResendEmailVerification) +// mint a 6-digit OTP and fire ActionEmailVerificationRequested carrying the code, +// which handleHookEvent delivers. Sending here as well would double-email the +// user (and the old payload had no token/code, so the link was unusable). +func (p *Plugin) OnAfterUserCreate(_ context.Context, _ *user.User) error { return nil } diff --git a/service.go b/service.go index 80b10b8a..45b8aff2 100644 --- a/service.go +++ b/service.go @@ -156,7 +156,7 @@ func (e *Engine) SignUp(ctx context.Context, req *account.SignUpRequest) (*user. // Issue an email-verification OTP for the new (unverified) user. Best-effort: // a failure to mint/deliver the code must not block signup. if !u.EmailVerified { - if issueErr := e.issueEmailVerificationForUser(ctx, u); issueErr != nil { + if _, issueErr := e.issueEmailVerificationForUser(ctx, u); issueErr != nil { e.logger.Warn("authsome: issue email verification failed", log.String("error", issueErr.Error())) } } @@ -1117,29 +1117,17 @@ func (e *Engine) SendEmailVerification(ctx context.Context, u *user.User) (strin if u == nil { return "", fmt.Errorf("authsome: send email verification: nil user") } - v, err := account.NewVerification(ctx, u.AppID, u.ID, account.VerificationEmail, EmailVerificationTTL) + + // Delegate to the canonical OTP issue path so signup and resend deliver the + // same 6-digit code (the notification layer renders {{code}}). + code, err := e.issueEmailVerificationForUser(ctx, u) if err != nil { - return "", fmt.Errorf("authsome: build verification: %w", err) - } - if storeErr := e.store.CreateVerification(ctx, v); storeErr != nil { - return "", fmt.Errorf("authsome: persist verification: %w", storeErr) + return "", err } - e.hooks.Emit(ctx, &hook.Event{ - Action: hook.ActionEmailVerificationRequested, - Resource: hook.ResourceUser, - ResourceID: u.ID.String(), - ActorID: u.ID.String(), - Tenant: u.AppID.String(), - Metadata: map[string]string{ - "email": u.Email, - "verification_token": v.Token, - "expires_at": v.ExpiresAt.UTC().Format(time.RFC3339), - }, - }) e.audit(ctx, bridge.SeverityInfo, bridge.OutcomeSuccess, "email_verification_requested", "user", u.ID.String(), u.ID.String(), u.AppID.String(), "auth", nil) - return v.Token, nil + return code, nil } // ResendEmailVerification is the public, enumeration-safe entry @@ -1247,18 +1235,21 @@ func (e *Engine) IssueEmailVerification(ctx context.Context, userID id.UserID) e if err != nil { return fmt.Errorf("authsome: get user: %w", err) } - return e.issueEmailVerificationForUser(ctx, u) + _, err = e.issueEmailVerificationForUser(ctx, u) + return err } -// issueEmailVerificationForUser is the internal path used by both -// IssueEmailVerification and SignUp (which already has the user loaded). -func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User) error { +// issueEmailVerificationForUser mints a 6-digit OTP, persists it, and fires +// ActionEmailVerificationRequested carrying the code. It is the single canonical +// issue path shared by SignUp, SendEmailVerification, and resend. Returns the +// generated code. +func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User) (string, error) { v, err := account.NewEmailVerificationCode(u.AppID, u.ID, emailVerificationTTL) if err != nil { - return fmt.Errorf("authsome: new email verification: %w", err) + return "", fmt.Errorf("authsome: new email verification: %w", err) } if createErr := e.store.CreateVerification(ctx, v); createErr != nil { - return fmt.Errorf("authsome: create verification: %w", createErr) + return "", fmt.Errorf("authsome: create verification: %w", createErr) } e.hooks.Emit(ctx, &hook.Event{ @@ -1268,12 +1259,13 @@ func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User ActorID: u.ID.String(), Tenant: u.AppID.String(), Metadata: map[string]string{ - "email": u.Email, - "user_name": u.Name(), - "code": v.Token, + "email": u.Email, + "user_name": u.Name(), + "code": v.Token, + "expires_at": v.ExpiresAt.UTC().Format(time.RFC3339), }, }) - return nil + return v.Token, nil } // VerifyEmailCode verifies a user's email using the 6-digit OTP code they were From 52f18a74c39646771c5b692e3c8f82588a344bb2 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 6 Jun 2026 19:44:45 +0100 Subject: [PATCH 4/7] fix(verification): route 6-digit token from authed session to OTP path The @authsome/ui-components EmailVerificationForm submits the OTP as {token: <6-digit code>}. Detect a 6-digit numeric value from an authenticated session user and verify it via the secure per-user VerifyEmailCode path (attempt-limited, constant-time) instead of the global token lookup; long tokens and the unauthenticated fallback still use VerifyEmail. Co-Authored-By: Claude Opus 4.8 --- api/password_handlers.go | 55 +++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/api/password_handlers.go b/api/password_handlers.go index fd634125..fea27ced 100644 --- a/api/password_handlers.go +++ b/api/password_handlers.go @@ -127,28 +127,49 @@ func (a *API) handleChangePassword(ctx forge.Context, req *ChangePasswordRequest } func (a *API) handleVerifyEmail(ctx forge.Context, req *VerifyEmailRequest) (*StatusResponse, error) { - // Code mode (OTP): verify the authenticated session user's email. The user - // is authenticated via the session minted at signup. - if req.Code != "" { - userID, ok := middleware.UserIDFrom(ctx.Context()) - if !ok { - return nil, forge.Unauthorized("authentication required to verify with a code") - } - if err := a.engine.VerifyEmailCode(ctx.Context(), userID, req.Code); err != nil { - return nil, mapError(err) - } - return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) + // The OTP form submits {token: <6-digit code>} (the @authsome/ui-components + // EmailVerificationForm). req.Code is also accepted for forward-compat. + candidate := req.Code + if candidate == "" { + candidate = req.Token + } + if candidate == "" { + return nil, forge.BadRequest("code or token is required") } - // Token mode (link-based flows, e.g. magic link). - if req.Token != "" { - if err := a.engine.VerifyEmail(ctx.Context(), req.Token); err != nil { - return nil, mapError(err) + // OTP path: a 6-digit numeric value from an authenticated session user (the + // account created at signup) is verified via the secure per-user code path + // (per-user lookup + attempt limiting + constant-time compare). We do NOT + // fall through to the global token lookup on failure — that could match a + // different user's identical code. + if isOTPCode(candidate) { + if userID, ok := middleware.UserIDFrom(ctx.Context()); ok { + if err := a.engine.VerifyEmailCode(ctx.Context(), userID, candidate); err != nil { + return nil, mapError(err) + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) } - return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) } - return nil, forge.BadRequest("code or token is required") + // Token path: long verification tokens (link flows, e.g. magic link), and + // the unauthenticated OTP fallback (global lookup by token). + if err := a.engine.VerifyEmail(ctx.Context(), candidate); err != nil { + return nil, mapError(err) + } + return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) +} + +// isOTPCode reports whether s is a 6-digit numeric OTP code. +func isOTPCode(s string) bool { + if len(s) != 6 { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true } func (a *API) handleResendVerification(ctx forge.Context, req *ResendVerificationRequest) (*StatusResponse, error) { From 16511dbaeb1a06c8e061552910fe3ab53e12bfcc Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 6 Jun 2026 20:37:14 +0100 Subject: [PATCH 5/7] fix(verification): set env_id on email verification record authsome_verifications.env_id is a non-null FK, but the OTP issue path never set it, so CreateVerification failed with fk_authsome_verifications_env and no code was ever minted. Populate EnvID from the app's default environment before insert. Co-Authored-By: Claude Opus 4.8 --- service.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/service.go b/service.go index 45b8aff2..bb803afe 100644 --- a/service.go +++ b/service.go @@ -1248,6 +1248,11 @@ func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User if err != nil { return "", fmt.Errorf("authsome: new email verification: %w", err) } + // env_id is a non-null FK on authsome_verifications — populate it from the + // app's default environment, else the insert is rejected. + if env, envErr := e.GetDefaultEnvironment(ctx, u.AppID); envErr == nil && env != nil { + v.EnvID = env.ID + } if createErr := e.store.CreateVerification(ctx, v); createErr != nil { return "", fmt.Errorf("authsome: create verification: %w", createErr) } From 21cbce06276fcd0d710ad0c13e54f1f00f14a2c9 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 7 Jun 2026 19:27:51 +0100 Subject: [PATCH 6/7] feat(verification): implement email verification with OTP and auto-login support --- api/api_test.go | 67 ++++++++++++++++++++++++++++++++++ api/auth_handlers.go | 27 ++++++++++++++ api/password_handlers.go | 54 +++++++++++++++++++++++---- api/requests.go | 7 ++++ config.go | 25 +++++++++---- engine.go | 6 +++ plugins/notification/config.go | 15 ++++++++ plugins/notification/plugin.go | 61 ++++++++++++++++++++++++++++++- service.go | 66 +++++++++++++++++++++++++++++++++ 9 files changed, 311 insertions(+), 17 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index edafb543..a99ee4f4 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1205,6 +1205,73 @@ func TestResendVerification_CreatesTokenForExistingUnverifiedUser(t *testing.T) require.Equal(t, "resend-target@example.com", captured["email"]) } +// TestForgotPassword_EmitsResetHookWithToken pins that POST /v1/forgot-password +// for a real user mints a reset token AND emits the auth.password_reset hook +// carrying that token + the user's email, so a wired-up notifier (the herald +// notification plugin) can deliver the reset link. +func TestForgotPassword_EmitsResetHookWithToken(t *testing.T) { + t.Parallel() + _, eng := newTestAPI(t) + router := newAPIWithRouter(t, eng) + ctx := context.Background() + + appID, err := id.ParseAppID(testAppIDStr) + require.NoError(t, err) + + _, _, err = eng.SignUp(ctx, &account.SignUpRequest{ + AppID: appID, + Email: "forgot-target@example.com", + Password: "SecureP@ss1", + }) + require.NoError(t, err) + + var captured map[string]string + eng.Hooks().On("test", func(_ context.Context, ev *hook.Event) error { + if ev.Action == hook.ActionPasswordReset { + captured = ev.Metadata + } + return nil + }) + + body := []byte(`{"email":"forgot-target@example.com"}`) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/forgot-password", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + require.NotNil(t, captured, "auth.password_reset hook must fire for a real user") + require.NotEmpty(t, captured["token"], "hook payload must carry the reset token for the delivery handler to build the link") + require.Equal(t, "forgot-target@example.com", captured["email"]) + require.NotEmpty(t, captured["expires_at"]) +} + +// TestForgotPassword_NoHookForUnknownEmail pins anti-enumeration: an unknown +// email still returns 200 but fires no hook (no reset token leaked, no signal +// that the address is unregistered). +func TestForgotPassword_NoHookForUnknownEmail(t *testing.T) { + t.Parallel() + _, eng := newTestAPI(t) + router := newAPIWithRouter(t, eng) + + var fired bool + eng.Hooks().On("test", func(_ context.Context, ev *hook.Event) error { + if ev.Action == hook.ActionPasswordReset { + fired = true + } + return nil + }) + + body := []byte(`{"email":"nobody-here@example.com"}`) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/forgot-password", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "forgot-password must return 200 for unknown emails (anti-enumeration)") + require.False(t, fired, "no password_reset hook may fire for an unregistered email") +} + // TestResendVerification_NoHookForVerifiedUser pins the silent no-op // path: a user who's already verified gets no fresh token and no hook // fires (otherwise an attacker could distinguish verified vs not by diff --git a/api/auth_handlers.go b/api/auth_handlers.go index 2ffdbd7a..da943b99 100644 --- a/api/auth_handlers.go +++ b/api/auth_handlers.go @@ -481,6 +481,33 @@ func authResponse(u *user.User, sess *session.Session) map[string]any { } } +// issueSessionForUser mints a fresh session for an already-authenticated user +// and returns the standard auth response shape (and sets the session cookie). +// Used by the email-verification auto-login path: signup intentionally withholds +// a client session until the email is verified, so on successful verification we +// sign the user in here rather than forcing a separate login round-trip. +// Returns an MFARequiredError when the per-app MFA gate fires; callers should +// fall back to a non-session status response in that case. +func (a *API) issueSessionForUser(ctx forge.Context, userID id.UserID, authMethod string) (map[string]any, error) { + u, err := a.engine.GetUser(ctx.Context(), userID) + if err != nil { + return nil, err + } + httpReq := ctx.Request() + res, err := a.engine.IssueSession(ctx.Context(), &authsome.IssueSessionRequest{ + User: u, + AppID: u.AppID, + AuthMethod: authMethod, + IPAddress: clientIPFromRequest(httpReq), + UserAgent: httpReq.UserAgent(), + }) + if err != nil { + return nil, err + } + a.setSessionCookie(ctx, res.Session.Token, a.sessionTokenMaxAge()) + return authResponse(res.User, res.Session), nil +} + // clientIPFromRequest extracts the client IP from the request, checking // X-Forwarded-For and X-Real-IP headers before falling back to RemoteAddr. func clientIPFromRequest(r *http.Request) string { diff --git a/api/password_handlers.go b/api/password_handlers.go index fea27ced..b0ca595e 100644 --- a/api/password_handlers.go +++ b/api/password_handlers.go @@ -5,6 +5,7 @@ import ( "github.com/xraph/forge" + "github.com/xraph/authsome/account" "github.com/xraph/authsome/middleware" ) @@ -52,25 +53,29 @@ func (a *API) registerPasswordRoutes(router forge.Router) error { return err } - if err := g.POST("/verify-email", a.handleVerifyEmail, + verifyOpts := []forge.RouteOption{ forge.WithSummary("Verify email"), - forge.WithDescription("Verifies a user's email address using a verification token."), + forge.WithDescription("Verifies a user's email address using a 6-digit OTP code (per-user, attempt-limited) or a verification token."), forge.WithOperationID("verifyEmail"), forge.WithRequestSchema(VerifyEmailRequest{}), forge.WithResponseSchema(http.StatusOK, "Email verified", StatusResponse{}), forge.WithErrorResponses(), - ); err != nil { + } + verifyOpts = append(verifyOpts, a.rateLimitOpt(rlCfg.VerifyEmailLimit)...) + if err := g.POST("/verify-email", a.handleVerifyEmail, verifyOpts...); err != nil { return err } - return g.POST("/verify-email/resend", a.handleResendVerification, + resendOpts := []forge.RouteOption{ forge.WithSummary("Resend email verification"), forge.WithDescription("Issues a fresh email verification token and emits the auth.email_verification_requested hook so a delivery handler (notification plugin or custom mailer) can send the link. Always returns 200 to avoid email-existence enumeration; callers cannot tell whether the email was registered or already verified."), forge.WithOperationID("resendEmailVerification"), forge.WithRequestSchema(ResendVerificationRequest{}), forge.WithResponseSchema(http.StatusOK, "Verification queued", StatusResponse{}), forge.WithErrorResponses(), - ) + } + resendOpts = append(resendOpts, a.rateLimitOpt(rlCfg.ResendVerificationLimit)...) + return g.POST("/verify-email/resend", a.handleResendVerification, resendOpts...) } // ────────────────────────────────────────────────── @@ -143,16 +148,49 @@ func (a *API) handleVerifyEmail(ctx forge.Context, req *VerifyEmailRequest) (*St // fall through to the global token lookup on failure — that could match a // different user's identical code. if isOTPCode(candidate) { - if userID, ok := middleware.UserIDFrom(ctx.Context()); ok { + // Resolve the user the code belongs to. Prefer the authenticated session + // user; when the request is unauthenticated (e.g. cross-origin signup + // where the session cookie isn't carried), fall back to the email in the + // body. Either way verification goes through the secure per-user code + // path (per-user lookup + attempt limiting + constant-time compare), so + // scoping by email is not a brute-force oracle. + userID, ok := middleware.UserIDFrom(ctx.Context()) + if !ok && req.Email != "" { + if appID, appErr := a.resolvePublicAppID(ctx, req.AppID); appErr == nil { + if u, userErr := a.engine.GetUserByEmail(ctx.Context(), appID, req.Email); userErr == nil { + userID = u.ID + ok = true + } + } + } + if ok { if err := a.engine.VerifyEmailCode(ctx.Context(), userID, candidate); err != nil { return nil, mapError(err) } + // Auto-login: signup withholds a client session until the email is + // verified, so on success we mint a fresh session and return the + // standard auth response. The SDK persists it (authsome:session), + // signing the user in without a second login round-trip. If session + // issuance trips the MFA gate (or otherwise fails), verification has + // still succeeded — fall back to a plain status so the client can + // route to its own login/MFA surface. + if resp, sessErr := a.issueSessionForUser(ctx, userID, "email_verification"); sessErr == nil { + return nil, ctx.JSON(http.StatusOK, resp) + } return nil, ctx.JSON(http.StatusOK, &StatusResponse{Status: "email verified"}) } + + // A 6-digit OTP that can't be tied to a specific user (no session and + // no/unknown email) MUST NOT fall through to the global token lookup + // below: GetVerification matches by token value alone, so a short code + // there is brute-forceable across the entire user pool with no per-user + // attempt limiting. Return the same generic error a wrong code yields so + // this isn't an email-existence oracle either. + return nil, mapError(account.ErrInvalidCredentials) } - // Token path: long verification tokens (link flows, e.g. magic link), and - // the unauthenticated OTP fallback (global lookup by token). + // Token path: high-entropy verification tokens only (link flows, e.g. magic + // link). 6-digit OTP codes never reach here (handled + returned above). if err := a.engine.VerifyEmail(ctx.Context(), candidate); err != nil { return nil, mapError(err) } diff --git a/api/requests.go b/api/requests.go index 82036b31..9f9f30f6 100644 --- a/api/requests.go +++ b/api/requests.go @@ -66,6 +66,13 @@ type ChangePasswordRequest struct { type VerifyEmailRequest struct { Code string `json:"code,omitempty" description:"6-digit OTP code (verifies the authenticated user's email)"` Token string `json:"token,omitempty" description:"Email verification token (link-based flows)"` + // Email scopes an OTP code to a specific user when the request is not + // authenticated (e.g. cross-origin signup where the session cookie is not + // carried). The code is still verified against that user's active + // verification with per-user attempt limiting, so it is not brute-forceable. + // Enables auto-login: on success a fresh session is issued and returned. + Email string `json:"email,omitempty" description:"Email to scope the OTP code to when unauthenticated (enables auto-login)"` + AppID string `json:"app_id,omitempty" description:"App ID (optional; resolved from publishable key when omitted)"` } // ResendVerificationRequest binds the body for POST /verify-email/resend. diff --git a/config.go b/config.go index eb8656ee..3792d588 100644 --- a/config.go +++ b/config.go @@ -145,6 +145,15 @@ type RateLimitConfig struct { // MFAChallengeLimit is the max MFA challenge attempts per window (default: 5). MFAChallengeLimit int `json:"mfa_challenge_limit"` + // VerifyEmailLimit is the max email-verification (code/token submit) + // attempts per window (default: 10). Defense-in-depth on top of the + // per-user OTP attempt cap. + VerifyEmailLimit int `json:"verify_email_limit"` + + // ResendVerificationLimit is the max resend-verification requests per + // window (default: 3). Caps how fast fresh codes can be minted. + ResendVerificationLimit int `json:"resend_verification_limit"` + // WindowSeconds is the sliding window duration in seconds (default: 60). WindowSeconds int `json:"window_seconds"` @@ -207,13 +216,15 @@ func DefaultConfig() Config { BcryptCost: 12, }, RateLimit: RateLimitConfig{ - SignInLimit: 5, - SignUpLimit: 3, - RefreshLimit: 10, - IntrospectLimit: 20, - ForgotPasswordLimit: 3, - MFAChallengeLimit: 5, - WindowSeconds: 60, + SignInLimit: 5, + SignUpLimit: 3, + RefreshLimit: 10, + IntrospectLimit: 20, + ForgotPasswordLimit: 3, + MFAChallengeLimit: 5, + VerifyEmailLimit: 10, + ResendVerificationLimit: 3, + WindowSeconds: 60, }, Lockout: LockoutConfig{ MaxAttempts: 5, diff --git a/engine.go b/engine.go index 63ddb4d9..cec7aa4b 100644 --- a/engine.go +++ b/engine.go @@ -564,6 +564,12 @@ func (e *Engine) GetUser(ctx context.Context, userID id.UserID) (*user.User, err return e.store.GetUser(ctx, userID) } +// GetUserByEmail looks up a user by any of their email addresses (primary or +// secondary) within an app. Returns store.ErrNotFound when no match exists. +func (e *Engine) GetUserByEmail(ctx context.Context, appID id.AppID, email string) (*user.User, error) { + return e.store.GetUserByAnyEmail(ctx, appID, id.Nil, email) +} + // Plugins returns the plugin registry. func (e *Engine) Plugins() *plugin.Registry { return e.plugins } diff --git a/plugins/notification/config.go b/plugins/notification/config.go index 6f890bbe..998a42ed 100644 --- a/plugins/notification/config.go +++ b/plugins/notification/config.go @@ -14,6 +14,21 @@ type Config struct { // (e.g. "https://example.com"). BaseURL string + // EmailVerifyPath is the path (relative to BaseURL) of the page where a + // user enters their email-verification code. It is used to synthesize the + // verify_url template variable for the email-verification notification + // (the default Herald template requires verify_url even though the OTP + // flow delivers a code rather than a link). Defaults to "/verify-email". + // The recipient's email is appended as an ?email= query parameter. + EmailVerifyPath string + + // PasswordResetPath is the path (relative to BaseURL) of the reset-password + // page. It is used to synthesize the reset_url template variable for the + // password-reset notification (the default Herald template requires + // reset_url). Defaults to "/reset-password". The reset token is appended as + // a ?token= query parameter. + PasswordResetPath string + // DefaultLocale is the default locale for notifications (e.g. "en"). // If empty, defaults to "en". DefaultLocale string diff --git a/plugins/notification/plugin.go b/plugins/notification/plugin.go index 7298ba10..4cc9ba9f 100644 --- a/plugins/notification/plugin.go +++ b/plugins/notification/plugin.go @@ -2,6 +2,9 @@ package notification import ( "context" + "net/url" + "strings" + "time" log "github.com/xraph/go-utils/log" @@ -110,6 +113,12 @@ func New(cfg ...Config) *Plugin { if c.DefaultLocale == "" { c.DefaultLocale = "en" } + if c.EmailVerifyPath == "" { + c.EmailVerifyPath = "/verify-email" + } + if c.PasswordResetPath == "" { + c.PasswordResetPath = "/reset-password" + } // Merge user-provided mappings with defaults. mappings := DefaultMappings() @@ -335,7 +344,10 @@ func (p *Plugin) handleHookEvent(ctx context.Context, event *hook.Event) error { } if len(to) == 0 { - // No recipient — skip silently. + // No recipient — skip. Logged so a misconfigured hook (missing email + // metadata) is diagnosable rather than failing invisibly. + p.logger.Warn("notification: no recipient resolved for action, skipping", + log.String("action", event.Action)) return nil } @@ -346,7 +358,47 @@ func (p *Plugin) handleHookEvent(ctx context.Context, event *hook.Event) error { } data["app_name"] = p.config.AppName - return p.herald.Notify(ctx, &bridge.HeraldNotifyRequest{ + // The default Herald auth.email-verification template marks verify_url as a + // required variable — it predates the OTP flow, which delivers a 6-digit + // code (event.Metadata["code"]) rather than a click-through link. Without a + // verify_url the renderer fails validation and Notify silently drops the + // email. Synthesize a deep-link to the code-entry page so the template + // renders (the page can read ?email= to pre-fill) and validation passes. + if event.Action == hook.ActionEmailVerificationRequested { + if existing, ok := data["verify_url"].(string); !ok || existing == "" { + verifyURL := strings.TrimRight(p.config.BaseURL, "/") + p.config.EmailVerifyPath + if email := event.Metadata["email"]; email != "" { + verifyURL += "?email=" + url.QueryEscape(email) + } + data["verify_url"] = verifyURL + } + } + + // Likewise the default auth.password-reset template requires reset_url. + // Synthesize it from the reset token so the email renders and the link + // lands on the reset-password page. + if event.Action == hook.ActionPasswordReset { + if existing, ok := data["reset_url"].(string); !ok || existing == "" { + resetURL := strings.TrimRight(p.config.BaseURL, "/") + p.config.PasswordResetPath + if token := event.Metadata["token"]; token != "" { + resetURL += "?token=" + url.QueryEscape(token) + } + data["reset_url"] = resetURL + } + } + + // Deliver on a context detached from the caller's request context. Hook + // handlers run inline on the originating request (e.g. signup), which has + // often already spent most of its deadline on password hashing, user + // creation and role assignment by the time this fires. Sending on that + // near-exhausted context makes a perfectly healthy provider call fail with + // "context deadline exceeded" and silently drops the email. Detaching (and + // granting a fresh timeout) makes delivery independent of how much budget + // the originating request had left. + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), notifyDeliveryTimeout) + defer cancel() + + return p.herald.Notify(sendCtx, &bridge.HeraldNotifyRequest{ AppID: event.Tenant, Template: m.Template, Channels: m.Channels, @@ -357,3 +409,8 @@ func (p *Plugin) handleHookEvent(ctx context.Context, event *hook.Event) error { Data: data, }) } + +// notifyDeliveryTimeout bounds a single hook-triggered notification delivery on +// its detached context. Generous enough to absorb a slow provider response +// without inheriting the originating request's (possibly tiny) remaining budget. +const notifyDeliveryTimeout = 30 * time.Second diff --git a/service.go b/service.go index bb803afe..f688877b 100644 --- a/service.go +++ b/service.go @@ -921,6 +921,30 @@ func (e *Engine) recordFailedSignin(ctx context.Context, req *account.SignInRequ // ForgotPassword creates a password reset token for the given email. // Returns the reset record (token can be sent via email by the caller). // Returns nil, nil if user not found (avoids email enumeration). +// humanizeDuration renders a duration as a short human-readable string for use +// in notification templates (e.g. "1 hour", "30 minutes"). Falls back to the +// stdlib string form for irregular durations. +func humanizeDuration(d time.Duration) string { + switch { + case d <= 0: + return "" + case d%time.Hour == 0: + if h := int(d / time.Hour); h == 1 { + return "1 hour" + } else { + return fmt.Sprintf("%d hours", h) + } + case d%time.Minute == 0: + if m := int(d / time.Minute); m == 1 { + return "1 minute" + } else { + return fmt.Sprintf("%d minutes", m) + } + default: + return d.String() + } +} + func (e *Engine) ForgotPassword(ctx context.Context, appID id.AppID, email string) (*account.PasswordReset, error) { if err := e.requireStarted(); err != nil { return nil, err @@ -939,10 +963,50 @@ func (e *Engine) ForgotPassword(ctx context.Context, appID id.AppID, email strin return nil, fmt.Errorf("authsome: create password reset: %w", err) } + // env_id is a non-null FK on authsome_password_resets — populate it from the + // app's default environment, else the insert is rejected (NewPasswordReset + // does not set it). + if env, envErr := e.GetDefaultEnvironment(ctx, appID); envErr == nil && env != nil { + pr.EnvID = env.ID + } else { + e.logger.Warn("authsome: no default environment resolved for password reset env_id", log.String("app_id", appID.String())) + } + if storeErr := e.store.CreatePasswordReset(ctx, pr); storeErr != nil { + e.logger.Warn("authsome: store password reset failed", log.String("error", storeErr.Error())) return nil, fmt.Errorf("authsome: store password reset: %w", storeErr) } + // u.Email may be empty when the user is loaded via the multi-email lookup + // (the address lives in the user_emails table). Fall back to the looked-up + // address so the notification always has a recipient. + notifyEmail := u.Email + if notifyEmail == "" { + notifyEmail = email + } + + // Emit the password-reset hook so a delivery handler (the herald + // notification plugin or a custom mailer) can send the reset link. The + // token is high-entropy (32 bytes), so carrying it in the link is safe. + e.hooks.Emit(ctx, &hook.Event{ + Action: hook.ActionPasswordReset, + Resource: hook.ResourceUser, + ResourceID: u.ID.String(), + ActorID: u.ID.String(), + Tenant: appID.String(), + Metadata: map[string]string{ + "email": notifyEmail, + "user_name": u.Name(), + "token": pr.Token, + // expires_at is the absolute timestamp; expires_in is the + // human-readable duration the reset template renders ("This link + // expires in {{.expires_in}}"). Herald does not apply template + // variable defaults at render time, so we must supply it. + "expires_at": pr.ExpiresAt.UTC().Format(time.RFC3339), + "expires_in": humanizeDuration(ttl), + }, + }) + e.audit(ctx, bridge.SeverityInfo, bridge.OutcomeSuccess, "forgot_password", "user", u.ID.String(), u.ID.String(), appID.String(), "auth", nil) e.relayEvent(ctx, "auth.forgot_password", appID.String(), map[string]string{ "user_id": u.ID.String(), @@ -1252,6 +1316,8 @@ func (e *Engine) issueEmailVerificationForUser(ctx context.Context, u *user.User // app's default environment, else the insert is rejected. if env, envErr := e.GetDefaultEnvironment(ctx, u.AppID); envErr == nil && env != nil { v.EnvID = env.ID + } else { + e.logger.Warn("authsome: no default environment resolved for verification env_id", log.String("app_id", u.AppID.String())) } if createErr := e.store.CreateVerification(ctx, v); createErr != nil { return "", fmt.Errorf("authsome: create verification: %w", createErr) From c46e0b32007eacafd1eaea4b57a397e4060540cd Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 7 Jun 2026 19:49:14 +0100 Subject: [PATCH 7/7] refactor(verification): streamline route option initialization for email verification --- api/password_handlers.go | 10 ++++++---- service.go | 12 ++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/api/password_handlers.go b/api/password_handlers.go index b0ca595e..51379175 100644 --- a/api/password_handlers.go +++ b/api/password_handlers.go @@ -53,27 +53,29 @@ func (a *API) registerPasswordRoutes(router forge.Router) error { return err } - verifyOpts := []forge.RouteOption{ + verifyOpts := make([]forge.RouteOption, 0, 7) //nolint:mnd // base options + rate limit + verifyOpts = append(verifyOpts, forge.WithSummary("Verify email"), forge.WithDescription("Verifies a user's email address using a 6-digit OTP code (per-user, attempt-limited) or a verification token."), forge.WithOperationID("verifyEmail"), forge.WithRequestSchema(VerifyEmailRequest{}), forge.WithResponseSchema(http.StatusOK, "Email verified", StatusResponse{}), forge.WithErrorResponses(), - } + ) verifyOpts = append(verifyOpts, a.rateLimitOpt(rlCfg.VerifyEmailLimit)...) if err := g.POST("/verify-email", a.handleVerifyEmail, verifyOpts...); err != nil { return err } - resendOpts := []forge.RouteOption{ + resendOpts := make([]forge.RouteOption, 0, 7) //nolint:mnd // base options + rate limit + resendOpts = append(resendOpts, forge.WithSummary("Resend email verification"), forge.WithDescription("Issues a fresh email verification token and emits the auth.email_verification_requested hook so a delivery handler (notification plugin or custom mailer) can send the link. Always returns 200 to avoid email-existence enumeration; callers cannot tell whether the email was registered or already verified."), forge.WithOperationID("resendEmailVerification"), forge.WithRequestSchema(ResendVerificationRequest{}), forge.WithResponseSchema(http.StatusOK, "Verification queued", StatusResponse{}), forge.WithErrorResponses(), - } + ) resendOpts = append(resendOpts, a.rateLimitOpt(rlCfg.ResendVerificationLimit)...) return g.POST("/verify-email/resend", a.handleResendVerification, resendOpts...) } diff --git a/service.go b/service.go index f688877b..19768d20 100644 --- a/service.go +++ b/service.go @@ -929,17 +929,17 @@ func humanizeDuration(d time.Duration) string { case d <= 0: return "" case d%time.Hour == 0: - if h := int(d / time.Hour); h == 1 { + h := int(d / time.Hour) + if h == 1 { return "1 hour" - } else { - return fmt.Sprintf("%d hours", h) } + return fmt.Sprintf("%d hours", h) case d%time.Minute == 0: - if m := int(d / time.Minute); m == 1 { + m := int(d / time.Minute) + if m == 1 { return "1 minute" - } else { - return fmt.Sprintf("%d minutes", m) } + return fmt.Sprintf("%d minutes", m) default: return d.String() }