diff --git a/server/errs/errors.go b/server/errs/errors.go index b7ad9dea9..61f403de7 100644 --- a/server/errs/errors.go +++ b/server/errs/errors.go @@ -21,6 +21,7 @@ const ( OtpExpired string = "otp-expired" OtpInvalidCode string = "otp-invalid-code" OtpTooManyAttempts string = "otp-too-many-attempts" + OtpRateLimited string = "otp-rate-limited" InvalidIdToken string = "invalid-id-token" ) diff --git a/server/models/otp.go b/server/models/otp.go index 3ea3276f5..b3b819bc9 100644 --- a/server/models/otp.go +++ b/server/models/otp.go @@ -12,4 +12,5 @@ type OtpCode struct { Code string `json:"-" bson:"code"` ExpiresAt time.Time `json:"-" bson:"expiresAt"` Attempts int `json:"-" bson:"attempts"` + CreatedAt time.Time `json:"-" bson:"createdAt"` } diff --git a/server/routes/auth.go b/server/routes/auth.go index a894a1bb3..9b45f6d2d 100644 --- a/server/routes/auth.go +++ b/server/routes/auth.go @@ -17,6 +17,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" "schej.it/server/db" "schej.it/server/errs" "schej.it/server/logger" @@ -367,6 +368,22 @@ func sendOtp(c *gin.Context) { email := strings.ToLower(strings.TrimSpace(payload.Email)) + // Rate limit: refuse to send a new code if one was already sent to this + // email within the cooldown window. Without this, the endpoint can be + // abused to bomb arbitrary inboxes with OTP emails (and to enumerate + // accounts by timing). The 5-attempt cap on verification is unaffected. + const otpSendCooldown = 30 * time.Second + var lastOtp models.OtpCode + findErr := db.OtpCodesCollection.FindOne( + context.Background(), + bson.M{"email": email}, + options.FindOne().SetSort(bson.M{"createdAt": -1}), + ).Decode(&lastOtp) + if findErr == nil && time.Since(lastOtp.CreatedAt) < otpSendCooldown { + c.JSON(http.StatusTooManyRequests, responses.Error{Error: errs.OtpRateLimited}) + return + } + // Delete any existing OTP codes for this email db.OtpCodesCollection.DeleteMany(context.Background(), bson.M{"email": email}) @@ -376,6 +393,7 @@ func sendOtp(c *gin.Context) { Code: code, ExpiresAt: time.Now().Add(10 * time.Minute), Attempts: 0, + CreatedAt: time.Now(), } _, err := db.OtpCodesCollection.InsertOne(context.Background(), otpDoc)