Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ 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"`
EnvID id.EnvironmentID `json:"env_id"`
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"`
Expand Down
62 changes: 62 additions & 0 deletions account/otp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
33 changes: 33 additions & 0 deletions account/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"strings"
"time"
"unicode"
Expand All @@ -29,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")
)

Expand Down Expand Up @@ -256,6 +258,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()
Expand Down
15 changes: 14 additions & 1 deletion account/store.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
package account

import "context"
import (
"context"

"github.com/xraph/authsome/id"
)

// Store defines the persistence interface for account lifecycle operations.
type Store interface {
CreateVerification(ctx context.Context, v *Verification) error
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
Expand Down
69 changes: 68 additions & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1200,11 +1200,78 @@ 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"])
}

// 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
Expand Down
27 changes: 27 additions & 0 deletions api/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading
Loading