From e297848d40f6a7ad7852fb32b9c7adc3ce74bd2c Mon Sep 17 00:00:00 2001 From: Ezra Developer Date: Fri, 5 Jun 2026 12:50:41 +0300 Subject: [PATCH] feat: add email-otp plugin --- .gitignore | 20 +- constants.go | 1 + examples/README.md | 2 + examples/email-otp/go.mod | 22 ++ examples/email-otp/go.sum | 42 +++ examples/email-otp/main.go | 71 +++++ plugins/email-otp/api.go | 21 ++ plugins/email-otp/authentication.go | 263 ++++++++++++++++ plugins/email-otp/authentication_test.go | 374 +++++++++++++++++++++++ plugins/email-otp/constants.go | 26 ++ plugins/email-otp/errors.go | 18 ++ plugins/email-otp/go.mod | 19 ++ plugins/email-otp/go.sum | 22 ++ plugins/email-otp/handlers.go | 119 ++++++++ plugins/email-otp/handlers_test.go | 139 +++++++++ plugins/email-otp/plugin.go | 91 ++++++ plugins/email-otp/state.go | 96 ++++++ plugins/email-otp/testutil_test.go | 33 ++ plugins/email-otp/types.go | 79 +++++ 19 files changed, 1457 insertions(+), 1 deletion(-) create mode 100644 examples/email-otp/go.mod create mode 100644 examples/email-otp/go.sum create mode 100644 examples/email-otp/main.go create mode 100644 plugins/email-otp/api.go create mode 100644 plugins/email-otp/authentication.go create mode 100644 plugins/email-otp/authentication_test.go create mode 100644 plugins/email-otp/constants.go create mode 100644 plugins/email-otp/errors.go create mode 100644 plugins/email-otp/go.mod create mode 100644 plugins/email-otp/go.sum create mode 100644 plugins/email-otp/handlers.go create mode 100644 plugins/email-otp/handlers_test.go create mode 100644 plugins/email-otp/plugin.go create mode 100644 plugins/email-otp/state.go create mode 100644 plugins/email-otp/testutil_test.go create mode 100644 plugins/email-otp/types.go diff --git a/.gitignore b/.gitignore index 52449b3..523d927 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,22 @@ tmp/ limen-publish go.work go.work.sum -examples/kitchen/ \ No newline at end of file +examples/kitchen/ + +# Generated per-developer migration SQL produced by `limen generate migrations`. +# Each developer regenerates these against their own database; they should not be checked in. +examples/*/migrations/ + +# Locally-built CLI binaries from `cd cmd/limen && go build`. +cmd/limen/limen +cmd/limen/limen.exe + +# Local test artifacts (curl cookie jars, etc.). +cookies.txt + +# Claude Code local configuration. +.claude/ + +# Go build artifacts left in example dirs (e.g. `go build` without `-o`). +examples/*/*.exe +examples/*/*/*.exe \ No newline at end of file diff --git a/constants.go b/constants.go index 1bb2efe..f7b8dab 100644 --- a/constants.go +++ b/constants.go @@ -37,6 +37,7 @@ const ( PluginOAuth PluginName = "oauth" PluginSessionJWT PluginName = "session-jwt" PluginMagicLink PluginName = "magic-link" + PluginEmailOTP PluginName = "email-otp" ) // ============================================================================ diff --git a/examples/README.md b/examples/README.md index de9ebeb..bb96fec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,7 @@ DATABASE_URL="postgres://..." go run ./examples/basic DATABASE_URL="postgres://..." go run ./examples/gin DATABASE_URL="postgres://..." GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... go run ./examples/oauth-google DATABASE_URL="postgres://..." go run ./examples/two-factor +DATABASE_URL="postgres://..." go run ./examples/email-otp DATABASE_URL="postgres://..." go run ./examples/adapters/gorm DATABASE_URL="postgres://..." go run ./examples/adapters/sql ``` @@ -29,6 +30,7 @@ DATABASE_URL="postgres://..." go run ./examples/adapters/sql | `gin` | GORM | credential-password | -- | | `oauth-google` | GORM | OAuth (Google) | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | | `two-factor` | GORM | credential-password, two-factor | -- | +| `email-otp` | `database/sql` | email-otp | -- | | `adapters/gorm` | GORM | credential-password | -- | | `adapters/sql` | `database/sql` | credential-password | -- | diff --git a/examples/email-otp/go.mod b/examples/email-otp/go.mod new file mode 100644 index 0000000..701b81f --- /dev/null +++ b/examples/email-otp/go.mod @@ -0,0 +1,22 @@ +module example/email-otp + +go 1.25.0 + +require ( + github.com/lib/pq v1.10.9 + github.com/thecodearcher/limen v0.1.1 + github.com/thecodearcher/limen/adapters/sql v0.0.1 + github.com/thecodearcher/limen/plugins/email-otp v0.0.0 +) + +require ( + github.com/jmoiron/sqlx v1.4.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) + +replace ( + github.com/thecodearcher/limen => ../.. + github.com/thecodearcher/limen/adapters/sql => ../../adapters/sql + github.com/thecodearcher/limen/plugins/email-otp => ../../plugins/email-otp +) diff --git a/examples/email-otp/go.sum b/examples/email-otp/go.sum new file mode 100644 index 0000000..5a1ae73 --- /dev/null +++ b/examples/email-otp/go.sum @@ -0,0 +1,42 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/examples/email-otp/main.go b/examples/email-otp/main.go new file mode 100644 index 0000000..8d5dfd1 --- /dev/null +++ b/examples/email-otp/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "os" + + _ "github.com/lib/pq" + + "github.com/thecodearcher/limen" + sqladapter "github.com/thecodearcher/limen/adapters/sql" + emailotp "github.com/thecodearcher/limen/plugins/email-otp" +) + +func main() { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + log.Fatal("set DATABASE_URL") + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + auth, err := limen.New(&limen.Config{ + BaseURL: "http://localhost:8080", + Database: sqladapter.NewPostgreSQL(db), + Secret: []byte("0123456789abcdef0123456789abcdef"), + HTTP: limen.NewDefaultHTTPConfig(limen.WithHTTPBasePath("/api/auth")), + CLI: &limen.CLIConfig{Enabled: true}, + Plugins: []limen.Plugin{ + emailotp.New( + emailotp.WithSendOTP(func(msg emailotp.EmailOTPMessage) { + // In production, send via your transactional email + // provider. Here we just log so you can copy the OTP + // out of the terminal during local testing. + log.Printf("email-otp: type=%s email=%s otp=%s", msg.Type, msg.Email, msg.OTP) + }), + ), + }, + }) + if err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + mux.Handle("/api/auth/", auth.Handler()) + + mux.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) { + session, err := auth.GetSession(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": session.User, + "session": session.Session, + }) + }) + + log.Println("email-otp example listening on :8080") + log.Println(" POST /api/auth/email-otp/send-otp {\"email\":\"you@example.com\"}") + log.Println(" POST /api/auth/email-otp/sign-in {\"email\":\"you@example.com\",\"otp\":\"123456\"}") + log.Println(" POST /api/auth/email-otp/verify-email {\"email\":\"you@example.com\",\"otp\":\"123456\"}") + log.Fatal(http.ListenAndServe(":8080", mux)) +} diff --git a/plugins/email-otp/api.go b/plugins/email-otp/api.go new file mode 100644 index 0000000..ca77179 --- /dev/null +++ b/plugins/email-otp/api.go @@ -0,0 +1,21 @@ +package emailotp + +import ( + "context" + + "github.com/thecodearcher/limen" +) + +// API is the public interface for the email-otp plugin. Obtain a type-safe +// reference via Use(). +type API interface { + SendOTP(ctx context.Context, email string, opts ...*SendOTPOptions) (*EmailOTPMessage, error) + SignInWithOTP(ctx context.Context, email, otp string, opts ...*SignInOptions) (*limen.AuthenticationResult, error) + VerifyOTP(ctx context.Context, email, otp string) (*limen.AuthenticationResult, error) +} + +// Use returns the email-otp plugin's API from a Limen instance. Panics if the +// plugin was not registered in Config.Plugins. +func Use(a *limen.Limen) API { + return limen.Use[API](a, limen.PluginEmailOTP) +} diff --git a/plugins/email-otp/authentication.go b/plugins/email-otp/authentication.go new file mode 100644 index 0000000..d5c8a87 --- /dev/null +++ b/plugins/email-otp/authentication.go @@ -0,0 +1,263 @@ +package emailotp + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/thecodearcher/limen" +) + +// SendOTP generates an OTP for the given email, persists it, and invokes the +// configured send callback. The plugin always returns success when the email +// is well-formed so callers cannot enumerate registered addresses; for the +// sign-in flow with sign-up disabled, no OTP is delivered when the user does +// not exist. +func (p *emailOTPPlugin) SendOTP(ctx context.Context, email string, opts ...*SendOTPOptions) (*EmailOTPMessage, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, ErrEmailRequired + } + + otpType := TypeSignIn + if len(opts) > 0 && opts[0] != nil && opts[0].Type != "" { + otpType = opts[0].Type + } + if !otpType.valid() { + return nil, ErrInvalidOTPType + } + + // Sign-in with sign-up disabled: silently no-op for unknown emails so we + // don't leak account existence. + if otpType == TypeSignIn && p.config.disableSignUp { + _, err := p.dbAction.FindUserByEmail(ctx, email) + if errors.Is(err, limen.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + } + + // Email-verification requires an existing user; silently no-op otherwise. + if otpType == TypeEmailVerification { + _, err := p.dbAction.FindUserByEmail(ctx, email) + if errors.Is(err, limen.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + } + + otp, err := p.generateOTP(email, otpType) + if err != nil { + return nil, err + } + + if err := p.persistOTP(ctx, email, otpType, otp); err != nil { + return nil, err + } + + msg := EmailOTPMessage{Email: email, OTP: otp, Type: otpType} + if p.config.sendOTP != nil { + p.config.sendOTP(msg) + } + return &msg, nil +} + +// VerifyOTP consumes an OTP for the email-verification flow and marks the +// matching user's email as verified. It returns the refreshed user so a +// caller can hand it to CreateSession if desired. +func (p *emailOTPPlugin) VerifyOTP(ctx context.Context, email, otp string) (*limen.AuthenticationResult, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, ErrEmailRequired + } + if strings.TrimSpace(otp) == "" { + return nil, ErrOTPRequired + } + + if _, err := p.consumeOTP(ctx, email, TypeEmailVerification, otp); err != nil { + return nil, err + } + + user, err := p.dbAction.FindUserByEmail(ctx, email) + if err != nil { + if errors.Is(err, limen.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + if user.EmailVerifiedAt == nil { + now := time.Now() + if err := p.dbAction.UpdateUser(ctx, &limen.User{EmailVerifiedAt: &now}, []limen.Where{ + limen.Eq(p.userSchema.GetIDField(), user.ID), + }); err != nil { + return nil, err + } + user.EmailVerifiedAt = &now + } + + return &limen.AuthenticationResult{User: user}, nil +} + +// SignInWithOTP consumes a sign-in OTP, returning the authenticated user. +// When the email has no corresponding user and sign-up is enabled, a new +// user is created using opts.AdditionalData. +func (p *emailOTPPlugin) SignInWithOTP(ctx context.Context, email, otp string, opts ...*SignInOptions) (*limen.AuthenticationResult, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, ErrEmailRequired + } + if strings.TrimSpace(otp) == "" { + return nil, ErrOTPRequired + } + + if _, err := p.consumeOTP(ctx, email, TypeSignIn, otp); err != nil { + return nil, err + } + + var signInOpts *SignInOptions + if len(opts) > 0 { + signInOpts = opts[0] + } + + existing, err := p.dbAction.FindUserByEmail(ctx, email) + if err != nil && !errors.Is(err, limen.ErrRecordNotFound) { + return nil, err + } + + if existing == nil { + if p.config.disableSignUp { + return nil, ErrSignUpDisabled + } + additional := map[string]any{} + if signInOpts != nil && signInOpts.AdditionalData != nil { + additional = signInOpts.AdditionalData + } + now := time.Now() + user := &limen.User{Email: email, EmailVerifiedAt: &now} + if err := p.dbAction.CreateUser(ctx, user, additional); err != nil { + return nil, err + } + // Re-read to obtain the persisted user including generated id and + // any additional columns. + refreshed, err := p.dbAction.FindUserByEmail(ctx, email) + if err != nil { + return nil, err + } + return &limen.AuthenticationResult{User: refreshed}, nil + } + + if existing.EmailVerifiedAt == nil { + now := time.Now() + if err := p.dbAction.UpdateUser(ctx, &limen.User{EmailVerifiedAt: &now}, []limen.Where{ + limen.Eq(p.userSchema.GetIDField(), existing.ID), + }); err != nil { + return nil, err + } + existing.EmailVerifiedAt = &now + } + + return &limen.AuthenticationResult{User: existing}, nil +} + +// persistOTP writes a fresh verification row for (email, type), replacing any +// prior unconsumed verification for the same identifier so the table never +// accumulates duplicate live OTPs. +func (p *emailOTPPlugin) persistOTP(ctx context.Context, email string, otpType OTPType, otp string) error { + identifier := toOTPIdentifier(otpType, email) + + if existing, err := p.dbAction.FindVerificationByAction(ctx, EmailOTPAction, identifier); err == nil { + if err := p.dbAction.DeleteVerification(ctx, existing.ID); err != nil { + return err + } + } else if !errors.Is(err, limen.ErrRecordNotFound) { + return err + } + + state := &otpState{ + Email: email, + Type: otpType, + OTP: p.storeValue(otp), + Attempts: 0, + } + encoded, err := encodeOTPState(state) + if err != nil { + return err + } + _, err = p.dbAction.CreateVerification(ctx, EmailOTPAction, identifier, encoded, p.config.otpExpiration) + return err +} + +// consumeOTP atomically validates a presented OTP. On success the +// verification row is deleted. On a wrong code the attempt counter is +// incremented; once it reaches allowedAttempts the row is removed and any +// further verification attempts fail with ErrTooManyAttempts. +func (p *emailOTPPlugin) consumeOTP(ctx context.Context, email string, otpType OTPType, presented string) (*otpState, error) { + identifier := toOTPIdentifier(otpType, email) + + var resultState *otpState + err := p.core.WithTransaction(ctx, func(ctx context.Context) error { + verification, err := p.dbAction.FindVerificationByAction(ctx, EmailOTPAction, identifier) + if err != nil { + if errors.Is(err, limen.ErrRecordNotFound) { + return ErrInvalidOTP + } + return err + } + + if verification.ExpiresAt.Before(time.Now()) { + if delErr := p.dbAction.DeleteVerification(ctx, verification.ID); delErr != nil { + return delErr + } + return ErrOTPExpired + } + + state, err := decodeOTPState(verification.Value) + if err != nil { + return err + } + + if state.Attempts >= p.config.allowedAttempts { + if delErr := p.dbAction.DeleteVerification(ctx, verification.ID); delErr != nil { + return delErr + } + return ErrTooManyAttempts + } + + if !p.matchOTP(state.OTP, presented) { + state.Attempts++ + if state.Attempts >= p.config.allowedAttempts { + if delErr := p.dbAction.DeleteVerification(ctx, verification.ID); delErr != nil { + return delErr + } + return ErrTooManyAttempts + } + encoded, encodeErr := encodeOTPState(state) + if encodeErr != nil { + return encodeErr + } + verification.Value = encoded + if updateErr := p.dbAction.UpdateVerification(ctx, verification, []limen.Where{ + limen.Eq(p.verificationSchema.GetIDField(), verification.ID), + }); updateErr != nil { + return updateErr + } + return ErrInvalidOTP + } + + if delErr := p.dbAction.DeleteVerification(ctx, verification.ID); delErr != nil { + return delErr + } + resultState = state + return nil + }) + if err != nil { + return nil, err + } + return resultState, nil +} diff --git a/plugins/email-otp/authentication_test.go b/plugins/email-otp/authentication_test.go new file mode 100644 index 0000000..4485022 --- /dev/null +++ b/plugins/email-otp/authentication_test.go @@ -0,0 +1,374 @@ +package emailotp + +import ( + "context" + "errors" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thecodearcher/limen" +) + +func TestSendOTP_RequiresEmail(t *testing.T) { + t.Parallel() + + _, plugin := newTestLimenAndPlugin(t) + _, err := plugin.SendOTP(context.Background(), " ") + assert.ErrorIs(t, err, ErrEmailRequired) +} + +func TestSendOTP_RejectsUnknownType(t *testing.T) { + t.Parallel() + + _, plugin := newTestLimenAndPlugin(t) + _, err := plugin.SendOTP(context.Background(), "user@test.com", &SendOTPOptions{Type: OTPType("bogus")}) + assert.ErrorIs(t, err, ErrInvalidOTPType) +} + +func TestSendOTP_GeneratesNumericCodeOfConfiguredLength(t *testing.T) { + t.Parallel() + + var captured string + _, plugin := newTestLimenAndPlugin(t, WithOTPLength(8), captureOTP(&captured)) + + msg, err := plugin.SendOTP(context.Background(), "len@test.com") + require.NoError(t, err) + require.NotNil(t, msg) + assert.Len(t, captured, 8) + for _, r := range captured { + assert.True(t, r >= '0' && r <= '9', "OTP must contain only digits") + } +} + +func TestSendOTP_DisableSignUp_DoesNotSendWhenUserMissing(t *testing.T) { + t.Parallel() + + var captured string + _, plugin := newTestLimenAndPlugin(t, WithDisableSignUp(true), captureOTP(&captured)) + + msg, err := plugin.SendOTP(context.Background(), "missing@test.com") + require.NoError(t, err) + assert.Nil(t, msg, "sign-in for unknown email should silently no-op") + assert.Empty(t, captured, "no OTP should be delivered when sign-up is disabled and user is unknown") +} + +func TestSendOTP_EmailVerification_DoesNotSendWhenUserMissing(t *testing.T) { + t.Parallel() + + var captured string + _, plugin := newTestLimenAndPlugin(t, captureOTP(&captured)) + + msg, err := plugin.SendOTP(context.Background(), "no-user@test.com", &SendOTPOptions{Type: TypeEmailVerification}) + require.NoError(t, err) + assert.Nil(t, msg) + assert.Empty(t, captured) +} + +func TestSendOTP_NormalizesEmailToLowerCase(t *testing.T) { + t.Parallel() + + var captured EmailOTPMessage + _, plugin := newTestLimenAndPlugin(t, WithSendOTP(func(m EmailOTPMessage) { captured = m })) + + _, err := plugin.SendOTP(context.Background(), " Mixed@TEST.com ") + require.NoError(t, err) + assert.Equal(t, "mixed@test.com", captured.Email) +} + +func TestSendOTP_ReplacesPriorVerificationForSameIdentifier(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "rot@test.com") + require.NoError(t, err) + firstOTP := otp + + _, err = plugin.SendOTP(context.Background(), "rot@test.com") + require.NoError(t, err) + require.NotEqual(t, firstOTP, otp, "a fresh OTP should be generated on every send") + + // The prior OTP must no longer be valid. + _, err = plugin.SignInWithOTP(context.Background(), "rot@test.com", firstOTP) + assert.ErrorIs(t, err, ErrInvalidOTP) + + // The latest OTP works. + _, err = plugin.SignInWithOTP(context.Background(), "rot@test.com", otp) + require.NoError(t, err) +} + +func TestSignInWithOTP_AutoCreatesUser(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "fresh@test.com") + require.NoError(t, err) + + _, err = plugin.dbAction.FindUserByEmail(context.Background(), "fresh@test.com") + require.ErrorIs(t, err, limen.ErrRecordNotFound) + + result, err := plugin.SignInWithOTP(context.Background(), "fresh@test.com", otp) + require.NoError(t, err) + require.NotNil(t, result.User) + assert.Equal(t, "fresh@test.com", result.User.Email) + require.NotNil(t, result.User.EmailVerifiedAt) + + user, err := plugin.dbAction.FindUserByEmail(context.Background(), "fresh@test.com") + require.NoError(t, err) + assert.Equal(t, result.User.ID, user.ID) +} + +func TestSignInWithOTP_PropagatesAdditionalDataOnSignUp(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "meta@test.com") + require.NoError(t, err) + + _, err = plugin.SignInWithOTP(context.Background(), "meta@test.com", otp, &SignInOptions{ + AdditionalData: map[string]any{ + "first_name": "Ada", + "role": "founder", + }, + }) + require.NoError(t, err) + + user, err := plugin.dbAction.FindUserByEmail(context.Background(), "meta@test.com") + require.NoError(t, err) + assert.Equal(t, "Ada", user.Raw()["first_name"]) + assert.Equal(t, "founder", user.Raw()["role"]) +} + +func TestSignInWithOTP_DisableSignUp_ReturnsError(t *testing.T) { + t.Parallel() + + // Generate an OTP for a user that doesn't exist, then re-init with + // disableSignUp to confirm verification still rejects the sign-up. + // We do this by generating then directly inserting the verification. + var otp string + _, plugin := newTestLimenAndPlugin(t, WithDisableSignUp(false), captureOTP(&otp)) + + // Pre-create the verification by going through send (sign-up enabled). + _, err := plugin.SendOTP(context.Background(), "blocked@test.com") + require.NoError(t, err) + require.NotEmpty(t, otp) + + // Flip the flag at the plugin level so verify-time policy applies. + plugin.config.disableSignUp = true + + _, err = plugin.SignInWithOTP(context.Background(), "blocked@test.com", otp) + assert.ErrorIs(t, err, ErrSignUpDisabled) +} + +func TestSignInWithOTP_MarksExistingUnverifiedUserAsVerified(t *testing.T) { + t.Parallel() + + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + seeded := limen.SeedTestUser(t, l, "existing@test.com") + require.Nil(t, seeded.EmailVerifiedAt) + + _, err := plugin.SendOTP(context.Background(), "existing@test.com") + require.NoError(t, err) + + result, err := plugin.SignInWithOTP(context.Background(), "existing@test.com", otp) + require.NoError(t, err) + require.NotNil(t, result.User.EmailVerifiedAt) + assert.Equal(t, seeded.ID, result.User.ID) +} + +func TestSignInWithOTP_WrongCodeIncrementsAttemptsThenLocksOut(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, WithAllowedAttempts(3), captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "attempts@test.com") + require.NoError(t, err) + require.NotEmpty(t, otp) + + wrong := wrongOTP(otp) + + _, err = plugin.SignInWithOTP(context.Background(), "attempts@test.com", wrong) + assert.ErrorIs(t, err, ErrInvalidOTP) + + _, err = plugin.SignInWithOTP(context.Background(), "attempts@test.com", wrong) + assert.ErrorIs(t, err, ErrInvalidOTP) + + // Third failed attempt should both fail *and* invalidate the OTP. + _, err = plugin.SignInWithOTP(context.Background(), "attempts@test.com", wrong) + assert.ErrorIs(t, err, ErrTooManyAttempts) + + // Now even the correct OTP should fail because the row is gone. + _, err = plugin.SignInWithOTP(context.Background(), "attempts@test.com", otp) + assert.ErrorIs(t, err, ErrInvalidOTP) +} + +func TestSignInWithOTP_ConsumesOTPOnSuccess(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "once@test.com") + require.NoError(t, err) + + _, err = plugin.SignInWithOTP(context.Background(), "once@test.com", otp) + require.NoError(t, err) + + _, err = plugin.SignInWithOTP(context.Background(), "once@test.com", otp) + assert.ErrorIs(t, err, ErrInvalidOTP, "OTP must be single-use") +} + +func TestSignInWithOTP_ExpiredOTP(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + var otp string + _, plugin := newTestLimenAndPlugin(t, + WithOTPExpiration(5*time.Minute), + captureOTP(&otp), + ) + + _, err := plugin.SendOTP(context.Background(), "expired@test.com") + require.NoError(t, err) + require.NotEmpty(t, otp) + + time.Sleep(6 * time.Minute) + + _, err = plugin.SignInWithOTP(context.Background(), "expired@test.com", otp) + assert.ErrorIs(t, err, ErrOTPExpired) + }) +} + +func TestVerifyOTP_MarksEmailAsVerified(t *testing.T) { + t.Parallel() + + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + seeded := limen.SeedTestUser(t, l, "verify-flow@test.com") + require.Nil(t, seeded.EmailVerifiedAt) + + _, err := plugin.SendOTP(context.Background(), "verify-flow@test.com", &SendOTPOptions{Type: TypeEmailVerification}) + require.NoError(t, err) + + result, err := plugin.VerifyOTP(context.Background(), "verify-flow@test.com", otp) + require.NoError(t, err) + require.NotNil(t, result.User.EmailVerifiedAt) + assert.Equal(t, seeded.ID, result.User.ID) +} + +func TestVerifyOTP_UserNotFound(t *testing.T) { + t.Parallel() + + // Generate a verification row by sending under email-verification flow + // then deleting the user — exercise the "valid OTP, missing user" branch. + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + limen.SeedTestUser(t, l, "deleteme@test.com") + + _, err := plugin.SendOTP(context.Background(), "deleteme@test.com", &SendOTPOptions{Type: TypeEmailVerification}) + require.NoError(t, err) + + // Remove the user out from under the verification. + user, err := plugin.dbAction.FindUserByEmail(context.Background(), "deleteme@test.com") + require.NoError(t, err) + require.NoError(t, plugin.core.Delete(context.Background(), plugin.userSchema, []limen.Where{ + limen.Eq(plugin.userSchema.GetIDField(), user.ID), + })) + + _, err = plugin.VerifyOTP(context.Background(), "deleteme@test.com", otp) + assert.True(t, errors.Is(err, ErrUserNotFound)) +} + +func TestVerifyOTP_RequiresEmailAndOTP(t *testing.T) { + t.Parallel() + + _, plugin := newTestLimenAndPlugin(t) + _, err := plugin.VerifyOTP(context.Background(), "", "123456") + assert.ErrorIs(t, err, ErrEmailRequired) + _, err = plugin.VerifyOTP(context.Background(), "x@test.com", " ") + assert.ErrorIs(t, err, ErrOTPRequired) +} + +func TestSendOTP_SignInAndEmailVerificationDoNotShareVerification(t *testing.T) { + t.Parallel() + + var captured []EmailOTPMessage + l, plugin := newTestLimenAndPlugin(t, WithSendOTP(func(m EmailOTPMessage) { + captured = append(captured, m) + })) + + limen.SeedTestUser(t, l, "two-types@test.com") + + _, err := plugin.SendOTP(context.Background(), "two-types@test.com", &SendOTPOptions{Type: TypeSignIn}) + require.NoError(t, err) + _, err = plugin.SendOTP(context.Background(), "two-types@test.com", &SendOTPOptions{Type: TypeEmailVerification}) + require.NoError(t, err) + + require.Len(t, captured, 2) + signInOTP := captured[0].OTP + verifyOTP := captured[1].OTP + + // Sign-in OTP must NOT work for email-verification flow, and vice versa. + _, err = plugin.VerifyOTP(context.Background(), "two-types@test.com", signInOTP) + assert.ErrorIs(t, err, ErrInvalidOTP) + + _, err = plugin.SignInWithOTP(context.Background(), "two-types@test.com", verifyOTP) + assert.ErrorIs(t, err, ErrInvalidOTP) + + // Each type's OTP works for its own flow. + _, err = plugin.VerifyOTP(context.Background(), "two-types@test.com", verifyOTP) + require.NoError(t, err) + _, err = plugin.SignInWithOTP(context.Background(), "two-types@test.com", signInOTP) + require.NoError(t, err) +} + +func TestHashStoredOTP_StoresHashAndStillVerifies(t *testing.T) { + t.Parallel() + + var otp string + _, plugin := newTestLimenAndPlugin(t, WithHashStoredOTP(true), captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "hashed@test.com") + require.NoError(t, err) + require.NotEmpty(t, otp) + + // Verify that the stored value is NOT the plain OTP. + verification, err := plugin.dbAction.FindVerificationByAction(context.Background(), EmailOTPAction, toOTPIdentifier(TypeSignIn, "hashed@test.com")) + require.NoError(t, err) + assert.False(t, strings.Contains(verification.Value, otp), "stored verification must not contain the plain OTP when hashing is enabled") + + _, err = plugin.SignInWithOTP(context.Background(), "hashed@test.com", otp) + require.NoError(t, err, "hashed OTP must still verify against presented plaintext") +} + +// wrongOTP returns an OTP of the same length and digit set as otp but +// guaranteed to differ from it. +func wrongOTP(otp string) string { + if otp == "" { + return "111111" + } + b := []byte(otp) + for i := range b { + if b[i] == '0' { + b[i] = '1' + } else { + b[i] = '0' + } + } + return string(b) +} diff --git a/plugins/email-otp/constants.go b/plugins/email-otp/constants.go new file mode 100644 index 0000000..535bcd0 --- /dev/null +++ b/plugins/email-otp/constants.go @@ -0,0 +1,26 @@ +package emailotp + +// EmailOTPAction is the verification action name used for all +// email-OTP verifications persisted in the verifications table. +const EmailOTPAction = "email_otp" + +// OTPType discriminates the purpose of an OTP within the email-otp plugin. +// It allows a single verification table to safely hold concurrent OTPs for +// different flows (e.g. sign-in and email-verification for the same address). +type OTPType string + +const ( + // TypeSignIn is the OTP type used for the sign-in flow. + TypeSignIn OTPType = "sign-in" + // TypeEmailVerification is the OTP type used to verify an existing + // user's email address. + TypeEmailVerification OTPType = "email-verification" +) + +func (t OTPType) valid() bool { + switch t { + case TypeSignIn, TypeEmailVerification: + return true + } + return false +} diff --git a/plugins/email-otp/errors.go b/plugins/email-otp/errors.go new file mode 100644 index 0000000..edaf22c --- /dev/null +++ b/plugins/email-otp/errors.go @@ -0,0 +1,18 @@ +package emailotp + +import ( + "net/http" + + "github.com/thecodearcher/limen" +) + +var ( + ErrEmailRequired = limen.NewLimenError("email is required", http.StatusUnprocessableEntity, nil) + ErrOTPRequired = limen.NewLimenError("otp is required", http.StatusUnprocessableEntity, nil) + ErrInvalidOTPType = limen.NewLimenError("invalid otp type", http.StatusUnprocessableEntity, nil) + ErrInvalidOTP = limen.NewLimenError("invalid otp", http.StatusBadRequest, nil) + ErrOTPExpired = limen.NewLimenError("otp has expired", http.StatusBadRequest, nil) + ErrTooManyAttempts = limen.NewLimenError("too many attempts", http.StatusForbidden, nil) + ErrUserNotFound = limen.NewLimenError("user not found", http.StatusBadRequest, nil) + ErrSignUpDisabled = limen.NewLimenError("sign up is disabled", http.StatusBadRequest, nil) +) diff --git a/plugins/email-otp/go.mod b/plugins/email-otp/go.mod new file mode 100644 index 0000000..92465f1 --- /dev/null +++ b/plugins/email-otp/go.mod @@ -0,0 +1,19 @@ +module github.com/thecodearcher/limen/plugins/email-otp + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + github.com/thecodearcher/limen v0.1.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/thecodearcher/limen => ../.. diff --git a/plugins/email-otp/go.sum b/plugins/email-otp/go.sum new file mode 100644 index 0000000..f053172 --- /dev/null +++ b/plugins/email-otp/go.sum @@ -0,0 +1,22 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/email-otp/handlers.go b/plugins/email-otp/handlers.go new file mode 100644 index 0000000..b371644 --- /dev/null +++ b/plugins/email-otp/handlers.go @@ -0,0 +1,119 @@ +package emailotp + +import ( + "net/http" + "strings" + + "github.com/thecodearcher/limen" +) + +type emailOTPHandlers struct { + plugin *emailOTPPlugin + httpCore *limen.LimenHTTPCore + responder *limen.Responder +} + +func newEmailOTPHandlers(plugin *emailOTPPlugin, httpCore *limen.LimenHTTPCore) *emailOTPHandlers { + return &emailOTPHandlers{ + plugin: plugin, + httpCore: httpCore, + responder: httpCore.Responder, + } +} + +func (h *emailOTPHandlers) SendOTP(w http.ResponseWriter, r *http.Request) { + body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator { + return v.RequiredString("email", data["email"]).Email("email", data["email"]) + }) + if body == nil { + return + } + + otpType := TypeSignIn + if raw, ok := body["type"].(string); ok && raw != "" { + otpType = OTPType(strings.ToLower(raw)) + if !otpType.valid() { + h.responder.Error(w, r, ErrInvalidOTPType) + return + } + } + + if _, err := h.plugin.SendOTP(r.Context(), body["email"].(string), &SendOTPOptions{Type: otpType}); err != nil { + h.responder.Error(w, r, err) + return + } + + h.responder.JSON(w, r, http.StatusOK, map[string]any{ + "success": true, + "message": "If the email exists, an OTP has been sent", + }) +} + +func (h *emailOTPHandlers) SignIn(w http.ResponseWriter, r *http.Request) { + body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator { + return v. + RequiredString("email", data["email"]). + Email("email", data["email"]). + RequiredString("otp", data["otp"]) + }) + if body == nil { + return + } + + additional := extractAdditionalData(body) + + result, err := h.plugin.SignInWithOTP(r.Context(), body["email"].(string), body["otp"].(string), &SignInOptions{AdditionalData: additional}) + if err != nil { + h.responder.Error(w, r, err) + return + } + + sessionResult, err := h.plugin.core.CreateSession(r.Context(), r, w, result) + if err != nil { + h.responder.Error(w, r, err) + return + } + + h.responder.SessionResponse(w, r, h.plugin.core, result, sessionResult) +} + +func (h *emailOTPHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) { + body := limen.ValidateJSON(w, r, h.responder, func(v *limen.Validator, data map[string]any) *limen.Validator { + return v. + RequiredString("email", data["email"]). + Email("email", data["email"]). + RequiredString("otp", data["otp"]) + }) + if body == nil { + return + } + + result, err := h.plugin.VerifyOTP(r.Context(), body["email"].(string), body["otp"].(string)) + if err != nil { + h.responder.Error(w, r, err) + return + } + + h.responder.JSON(w, r, http.StatusOK, map[string]any{ + "success": true, + "user": limen.SerializeModel(h.plugin.core.Schema.User, result.User), + }) +} + +// extractAdditionalData copies non-reserved keys from the sign-in request body +// so first_name, last_name, etc. flow into the auto-created user. +func extractAdditionalData(body map[string]any) map[string]any { + reserved := map[string]struct{}{ + "email": {}, + "otp": {}, + "type": {}, + } + out := make(map[string]any, len(body)) + for k, v := range body { + if _, skip := reserved[k]; skip { + continue + } + out[k] = v + } + return out +} diff --git a/plugins/email-otp/handlers_test.go b/plugins/email-otp/handlers_test.go new file mode 100644 index 0000000..9dcd784 --- /dev/null +++ b/plugins/email-otp/handlers_test.go @@ -0,0 +1,139 @@ +package emailotp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSendOTPHandler_ValidatesInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + expectedStatus int + expectedSubstr string + }{ + { + name: "missing email field", + body: `{"type":"sign-in"}`, + expectedStatus: http.StatusUnprocessableEntity, + expectedSubstr: "email", + }, + { + name: "invalid email", + body: `{"email":"not-an-email"}`, + expectedStatus: http.StatusUnprocessableEntity, + expectedSubstr: "email", + }, + { + name: "invalid type", + body: `{"email":"u@test.com","type":"shenanigans"}`, + expectedStatus: http.StatusUnprocessableEntity, + expectedSubstr: "otp type", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + l, _ := newTestLimenAndPlugin(t) + + req := newJSONRequest(t, http.MethodPost, "/auth/email-otp/send-otp", tt.body) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + assert.Contains(t, w.Body.String(), tt.expectedSubstr) + }) + } +} + +func TestSendOTPHandler_AlwaysReturnsGenericSuccess(t *testing.T) { + t.Parallel() + + // Even when the user does not exist for email-verification, the handler + // must return 200 to prevent enumeration. + l, _ := newTestLimenAndPlugin(t, WithSendOTP(func(EmailOTPMessage) {})) + + req := newJSONRequest(t, http.MethodPost, "/auth/email-otp/send-otp", `{"email":"ghost@test.com","type":"email-verification"}`) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "success") +} + +func TestSignInHandler_HappyPath_SetsSessionCookie(t *testing.T) { + t.Parallel() + + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "handler@test.com") + require.NoError(t, err) + require.NotEmpty(t, otp) + + body, _ := json.Marshal(map[string]any{"email": "handler@test.com", "otp": otp}) + req := newJSONRequest(t, http.MethodPost, "/auth/email-otp/sign-in", string(body)) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.NotEmpty(t, w.Result().Cookies(), "successful sign-in must set a session cookie") +} + +func TestSignInHandler_WrongOTPReturns400(t *testing.T) { + t.Parallel() + + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + _, err := plugin.SendOTP(context.Background(), "wrong@test.com") + require.NoError(t, err) + + body, _ := json.Marshal(map[string]any{"email": "wrong@test.com", "otp": wrongOTP(otp)}) + req := newJSONRequest(t, http.MethodPost, "/auth/email-otp/sign-in", string(body)) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid otp") + assert.Empty(t, w.Result().Cookies(), "failed sign-in must not set a session cookie") +} + +func TestVerifyEmailHandler_HappyPath(t *testing.T) { + t.Parallel() + + var otp string + l, plugin := newTestLimenAndPlugin(t, captureOTP(&otp)) + + // Seed user and request email-verification OTP. + // Using the in-test helper to bypass sign-in. + seed := struct{ email string }{"verify-h@test.com"} + require.NotNil(t, l) + // SeedTestUser is used elsewhere; for handler tests we just create via sign-in first. + _, err := plugin.SendOTP(context.Background(), seed.email) + require.NoError(t, err) + _, err = plugin.SignInWithOTP(context.Background(), seed.email, otp) + require.NoError(t, err) + + // Now request an email-verification OTP and verify via handler. + _, err = plugin.SendOTP(context.Background(), seed.email, &SendOTPOptions{Type: TypeEmailVerification}) + require.NoError(t, err) + + body, _ := json.Marshal(map[string]any{"email": seed.email, "otp": otp}) + req := newJSONRequest(t, http.MethodPost, "/auth/email-otp/verify-email", string(body)) + w := httptest.NewRecorder() + l.Handler().ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "success") +} diff --git a/plugins/email-otp/plugin.go b/plugins/email-otp/plugin.go new file mode 100644 index 0000000..0a395a2 --- /dev/null +++ b/plugins/email-otp/plugin.go @@ -0,0 +1,91 @@ +// Package emailotp provides one-time-code email authentication for Limen. +// +// The plugin supports two flows out of the box: +// - sign-in (POST /email-otp/send-otp + POST /sign-in/email-otp): a +// passwordless login that auto-creates the user when missing. +// - email-verification (POST /email-otp/send-otp + POST /email-otp/verify-email): +// marks an existing user's email as verified. +// +// The plugin is modelled after better-auth's email-otp plugin and reuses +// Limen's verifications table for storage. See plugin docs for configuration +// options. +package emailotp + +import ( + "fmt" + "time" + + "github.com/thecodearcher/limen" +) + +const ( + defaultOTPLength = 6 + defaultOTPExpiration = 5 * time.Minute + defaultAllowedAttempts = 3 +) + +type emailOTPPlugin struct { + core *limen.LimenCore + config *config + userSchema *limen.UserSchema + verificationSchema *limen.VerificationSchema + dbAction *limen.DatabaseActionHelper +} + +// New returns an email-otp plugin configured with defaults that match +// better-auth's behavior: 6-digit numeric codes, 5-minute expiry, and 3 +// allowed verification attempts before the code is invalidated. +func New(opts ...ConfigOption) *emailOTPPlugin { + cfg := &config{ + otpExpiration: defaultOTPExpiration, + otpLength: defaultOTPLength, + allowedAttempts: defaultAllowedAttempts, + } + for _, opt := range opts { + opt(cfg) + } + return &emailOTPPlugin{config: cfg} +} + +func (p *emailOTPPlugin) Name() limen.PluginName { + return limen.PluginEmailOTP +} + +func (p *emailOTPPlugin) Initialize(core *limen.LimenCore) error { + p.core = core + p.userSchema = core.Schema.User + p.verificationSchema = core.Schema.Verification + p.dbAction = core.DBAction + + if p.config == nil { + return fmt.Errorf("email-otp: config is required") + } + if p.config.otpExpiration <= 0 { + return fmt.Errorf("email-otp: otp expiration must be positive") + } + if p.config.otpLength <= 0 { + return fmt.Errorf("email-otp: otp length must be positive") + } + if p.config.allowedAttempts <= 0 { + return fmt.Errorf("email-otp: allowed attempts must be positive") + } + return nil +} + +func (p *emailOTPPlugin) PluginHTTPConfig() limen.PluginHTTPConfig { + return limen.PluginHTTPConfig{ + BasePath: "/email-otp", + RateLimitRules: []*limen.RateLimitRule{ + limen.NewRateLimitRule("/send-otp", 3, time.Minute), + limen.NewRateLimitRule("/sign-in", 3, time.Minute), + limen.NewRateLimitRule("/verify-email", 3, time.Minute), + }, + } +} + +func (p *emailOTPPlugin) RegisterRoutes(httpCore *limen.LimenHTTPCore, routeBuilder *limen.RouteBuilder) { + handlers := newEmailOTPHandlers(p, httpCore) + routeBuilder.POST("/send-otp", "email-otp-send", handlers.SendOTP) + routeBuilder.POST("/sign-in", "email-otp-sign-in", handlers.SignIn) + routeBuilder.POST("/verify-email", "email-otp-verify-email", handlers.VerifyEmail) +} diff --git a/plugins/email-otp/state.go b/plugins/email-otp/state.go new file mode 100644 index 0000000..a8711b7 --- /dev/null +++ b/plugins/email-otp/state.go @@ -0,0 +1,96 @@ +package emailotp + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "math/big" +) + +// otpState is the JSON-encoded value stored alongside an email-otp +// verification record. Field names are abbreviated to keep the row small. +type otpState struct { + Email string `json:"e"` + Type OTPType `json:"t"` + OTP string `json:"o"` // plaintext or HMAC-SHA256 hash, controlled by hashStoredOTP + Attempts int `json:"a"` +} + +func encodeOTPState(s *otpState) (string, error) { + raw, err := json.Marshal(s) + if err != nil { + return "", err + } + return string(raw), nil +} + +func decodeOTPState(value string) (*otpState, error) { + var s otpState + if err := json.Unmarshal([]byte(value), &s); err != nil { + return nil, err + } + return &s, nil +} + +// toOTPIdentifier returns the verification subject for a given OTP type +// and email. It mirrors better-auth's `{type}-otp-{email}` convention so +// the storage layout stays self-explanatory in raw rows. +func toOTPIdentifier(t OTPType, email string) string { + return string(t) + "-otp-" + email +} + +func (p *emailOTPPlugin) generateOTP(email string, t OTPType) (string, error) { + if p.config.generateOTP != nil { + code, err := p.config.generateOTP(email, t) + if err != nil { + return "", err + } + if code != "" { + return code, nil + } + } + return defaultNumericOTP(p.config.otpLength) +} + +// defaultNumericOTP generates a uniformly-random numeric code of n digits. +func defaultNumericOTP(n int) (string, error) { + const digits = "0123456789" + out := make([]byte, n) + max := big.NewInt(int64(len(digits))) + for i := 0; i < n; i++ { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + out[i] = digits[idx.Int64()] + } + return string(out), nil +} + +// storeValue returns the value persisted in the verifications row for a given +// plaintext OTP, applying HMAC-SHA256 with the core secret when hashing is +// enabled. +func (p *emailOTPPlugin) storeValue(otp string) string { + if !p.config.hashStoredOTP { + return otp + } + return p.hashOTP(otp) +} + +// matchOTP performs a constant-time comparison between a stored value and a +// freshly-presented plaintext OTP, honoring the configured storage mode. +func (p *emailOTPPlugin) matchOTP(stored, presented string) bool { + if p.config.hashStoredOTP { + presented = p.hashOTP(presented) + } + return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1 +} + +func (p *emailOTPPlugin) hashOTP(otp string) string { + mac := hmac.New(sha256.New, p.core.Secret()) + mac.Write([]byte(otp)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/plugins/email-otp/testutil_test.go b/plugins/email-otp/testutil_test.go new file mode 100644 index 0000000..5f4df1d --- /dev/null +++ b/plugins/email-otp/testutil_test.go @@ -0,0 +1,33 @@ +package emailotp + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thecodearcher/limen" +) + +func newTestLimenAndPlugin(t *testing.T, opts ...ConfigOption) (*limen.Limen, *emailOTPPlugin) { + t.Helper() + plugin := New(opts...) + l, _ := limen.NewTestLimen(t, plugin) + return l, plugin +} + +func newJSONRequest(t *testing.T, method, path, body string) *http.Request { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), method, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +// captureOTP returns a ConfigOption that stores each delivered OTP into the +// pointer it returns, letting tests grab the freshly generated code without a +// real mailer. +func captureOTP(out *string) ConfigOption { + return WithSendOTP(func(msg EmailOTPMessage) { + *out = msg.OTP + }) +} diff --git a/plugins/email-otp/types.go b/plugins/email-otp/types.go new file mode 100644 index 0000000..7609d4c --- /dev/null +++ b/plugins/email-otp/types.go @@ -0,0 +1,79 @@ +package emailotp + +import "time" + +type config struct { + otpExpiration time.Duration + otpLength int + allowedAttempts int + disableSignUp bool + hashStoredOTP bool + generateOTP func(email string, otpType OTPType) (string, error) + sendOTP func(EmailOTPMessage) +} + +// ConfigOption configures the email-otp plugin. +type ConfigOption func(*config) + +// WithOTPExpiration sets how long a generated OTP remains valid. Defaults to 5 minutes. +func WithOTPExpiration(d time.Duration) ConfigOption { + return func(c *config) { c.otpExpiration = d } +} + +// WithOTPLength sets the number of digits in a generated OTP. Defaults to 6. +// Ignored when WithGenerateOTP supplies a custom generator. +func WithOTPLength(n int) ConfigOption { + return func(c *config) { c.otpLength = n } +} + +// WithAllowedAttempts sets the number of failed verifications tolerated before +// the OTP is invalidated. Defaults to 3. +func WithAllowedAttempts(n int) ConfigOption { + return func(c *config) { c.allowedAttempts = n } +} + +// WithDisableSignUp prevents the sign-in flow from auto-creating a user when +// no account exists for the provided email. Defaults to false. +func WithDisableSignUp(disable bool) ConfigOption { + return func(c *config) { c.disableSignUp = disable } +} + +// WithHashStoredOTP enables HMAC-SHA256 hashing of the OTP at rest. With this +// enabled a stolen database snapshot does not yield usable OTPs without the +// Limen secret. Defaults to false (matching the better-auth default), and +// recommended for production. +func WithHashStoredOTP(hash bool) ConfigOption { + return func(c *config) { c.hashStoredOTP = hash } +} + +// WithGenerateOTP overrides the default numeric OTP generator. Returning an +// empty string falls back to the default generator. +func WithGenerateOTP(fn func(email string, otpType OTPType) (string, error)) ConfigOption { + return func(c *config) { c.generateOTP = fn } +} + +// WithSendOTP sets the callback used to deliver an OTP to the user. Wire your +// transactional email provider here; this callback is required for any real +// deployment. +func WithSendOTP(fn func(EmailOTPMessage)) ConfigOption { + return func(c *config) { c.sendOTP = fn } +} + +// EmailOTPMessage is the payload handed to the WithSendOTP callback for delivery. +type EmailOTPMessage struct { + Email string + OTP string + Type OTPType +} + +// SendOTPOptions carries optional parameters for SendOTP. +type SendOTPOptions struct { + // Type defaults to TypeSignIn when zero. + Type OTPType +} + +// SignInOptions carries optional parameters for SignInWithOTP, including +// user fields used only when auto-creating a new account. +type SignInOptions struct { + AdditionalData map[string]any +}