From 6f80f0b11ce2e8fc5b86b1bca0f410b21b0f0f3a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 31 Mar 2026 17:50:39 +0530 Subject: [PATCH] feat: add login attempt schema and storage for sliding window rate limiting Add LoginAttempt schema to track individual login attempts with email, IP address, success status, and timestamp. Implements AddLoginAttempt, CountFailedLoginAttemptsSince (for sliding window lockout), and DeleteLoginAttemptsBefore (for cleanup) across all 6 DB providers. Includes integration tests. Ref: RFC #501. --- .../integration_tests/login_attempts_test.go | 131 ++++++++++++++++++ internal/storage/db/arangodb/login_attempt.go | 66 +++++++++ internal/storage/db/arangodb/provider.go | 22 +++ .../storage/db/cassandradb/login_attempt.go | 81 +++++++++++ internal/storage/db/cassandradb/provider.go | 17 +++ .../storage/db/couchbase/login_attempt.go | 78 +++++++++++ internal/storage/db/couchbase/provider.go | 5 + internal/storage/db/dynamodb/login_attempt.go | 55 ++++++++ internal/storage/db/dynamodb/provider.go | 1 + internal/storage/db/mongodb/login_attempt.go | 52 +++++++ internal/storage/db/mongodb/provider.go | 14 ++ internal/storage/db/sql/login_attempt.go | 46 ++++++ internal/storage/db/sql/provider.go | 2 +- internal/storage/provider.go | 7 + internal/storage/schemas/login_attempt.go | 14 ++ internal/storage/schemas/model.go | 2 + 16 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 internal/integration_tests/login_attempts_test.go create mode 100644 internal/storage/db/arangodb/login_attempt.go create mode 100644 internal/storage/db/cassandradb/login_attempt.go create mode 100644 internal/storage/db/couchbase/login_attempt.go create mode 100644 internal/storage/db/dynamodb/login_attempt.go create mode 100644 internal/storage/db/mongodb/login_attempt.go create mode 100644 internal/storage/db/sql/login_attempt.go create mode 100644 internal/storage/schemas/login_attempt.go diff --git a/internal/integration_tests/login_attempts_test.go b/internal/integration_tests/login_attempts_test.go new file mode 100644 index 00000000..1319b205 --- /dev/null +++ b/internal/integration_tests/login_attempts_test.go @@ -0,0 +1,131 @@ +package integration_tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLoginAttempts tests AddLoginAttempt, CountFailedLoginAttemptsSince, and DeleteLoginAttemptsBefore +func TestLoginAttempts(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + ctx := context.Background() + + email := "ratelimit-test@example.com" + ipAddress := "192.0.2.1" + now := time.Now().Unix() + oneHourAgo := now - 3600 + + t.Run("should add a failed login attempt", func(t *testing.T) { + attempt := &schemas.LoginAttempt{ + Email: email, + IPAddress: ipAddress, + Successful: false, + AttemptedAt: now, + } + err := ts.StorageProvider.AddLoginAttempt(ctx, attempt) + require.NoError(t, err) + assert.NotEmpty(t, attempt.ID) + assert.Equal(t, attempt.Key, attempt.ID) + assert.NotZero(t, attempt.CreatedAt) + }) + + t.Run("should add a successful login attempt", func(t *testing.T) { + attempt := &schemas.LoginAttempt{ + Email: email, + IPAddress: ipAddress, + Successful: true, + AttemptedAt: now, + } + err := ts.StorageProvider.AddLoginAttempt(ctx, attempt) + require.NoError(t, err) + assert.NotEmpty(t, attempt.ID) + }) + + t.Run("should count only failed attempts since timestamp", func(t *testing.T) { + count, err := ts.StorageProvider.CountFailedLoginAttemptsSince(ctx, email, oneHourAgo) + require.NoError(t, err) + // We added 1 failed attempt above + assert.GreaterOrEqual(t, count, int64(1)) + }) + + t.Run("should not count successful attempts", func(t *testing.T) { + // Add a second email with only successful attempts + successEmail := "success-only@example.com" + attempt := &schemas.LoginAttempt{ + Email: successEmail, + IPAddress: ipAddress, + Successful: true, + AttemptedAt: now, + } + err := ts.StorageProvider.AddLoginAttempt(ctx, attempt) + require.NoError(t, err) + + count, err := ts.StorageProvider.CountFailedLoginAttemptsSince(ctx, successEmail, oneHourAgo) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + }) + + t.Run("should not count attempts before the since timestamp", func(t *testing.T) { + oldEmail := "old-attempts@example.com" + twoHoursAgo := now - 7200 + attempt := &schemas.LoginAttempt{ + Email: oldEmail, + IPAddress: ipAddress, + Successful: false, + AttemptedAt: twoHoursAgo, + } + err := ts.StorageProvider.AddLoginAttempt(ctx, attempt) + require.NoError(t, err) + + // Count since one hour ago — the attempt is two hours old, should not be counted + count, err := ts.StorageProvider.CountFailedLoginAttemptsSince(ctx, oldEmail, oneHourAgo) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + }) + + t.Run("should delete attempts before timestamp", func(t *testing.T) { + cleanupEmail := "cleanup@example.com" + twoHoursAgo := now - 7200 + + // Add an old failed attempt + oldAttempt := &schemas.LoginAttempt{ + Email: cleanupEmail, + IPAddress: ipAddress, + Successful: false, + AttemptedAt: twoHoursAgo, + } + err := ts.StorageProvider.AddLoginAttempt(ctx, oldAttempt) + require.NoError(t, err) + + // Add a recent failed attempt + recentAttempt := &schemas.LoginAttempt{ + Email: cleanupEmail, + IPAddress: ipAddress, + Successful: false, + AttemptedAt: now, + } + err = ts.StorageProvider.AddLoginAttempt(ctx, recentAttempt) + require.NoError(t, err) + + // Verify both attempts count before cleanup + countBefore, err := ts.StorageProvider.CountFailedLoginAttemptsSince(ctx, cleanupEmail, twoHoursAgo-1) + require.NoError(t, err) + assert.GreaterOrEqual(t, countBefore, int64(2)) + + // Delete attempts older than one hour ago + err = ts.StorageProvider.DeleteLoginAttemptsBefore(ctx, oneHourAgo) + require.NoError(t, err) + + // Only the recent attempt should remain (not older than oneHourAgo) + countAfter, err := ts.StorageProvider.CountFailedLoginAttemptsSince(ctx, cleanupEmail, twoHoursAgo-1) + require.NoError(t, err) + assert.Equal(t, int64(1), countAfter) + }) +} diff --git a/internal/storage/db/arangodb/login_attempt.go b/internal/storage/db/arangodb/login_attempt.go new file mode 100644 index 00000000..50a7cc97 --- /dev/null +++ b/internal/storage/db/arangodb/login_attempt.go @@ -0,0 +1,66 @@ +package arangodb + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + loginAttemptCollection, _ := p.db.Collection(ctx, schemas.Collections.LoginAttempt) + _, err := loginAttemptCollection.CreateDocument(ctx, loginAttempt) + return err +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + query := fmt.Sprintf( + "RETURN LENGTH(FOR d IN %s FILTER d.email == @email AND d.successful == false AND d.attempted_at >= @since RETURN 1)", + schemas.Collections.LoginAttempt, + ) + bindVariables := map[string]interface{}{ + "email": email, + "since": since, + } + cursor, err := p.db.Query(ctx, query, bindVariables) + if err != nil { + return 0, err + } + defer cursor.Close() + var count int64 + _, err = cursor.ReadDocument(ctx, &count) + if err != nil { + return 0, err + } + return count, nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + query := fmt.Sprintf( + "FOR d IN %s FILTER d.attempted_at < @before REMOVE d IN %s", + schemas.Collections.LoginAttempt, schemas.Collections.LoginAttempt, + ) + bindVariables := map[string]interface{}{ + "before": before, + } + cursor, err := p.db.Query(ctx, query, bindVariables) + if err != nil { + return err + } + cursor.Close() + return nil +} diff --git a/internal/storage/db/arangodb/provider.go b/internal/storage/db/arangodb/provider.go index ea3db9be..77d8bcc9 100644 --- a/internal/storage/db/arangodb/provider.go +++ b/internal/storage/db/arangodb/provider.go @@ -323,6 +323,28 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { Sparse: true, }) + // LoginAttempt collection and indexes + loginAttemptCollectionExists, err := arangodb.CollectionExists(ctx, schemas.Collections.LoginAttempt) + if err != nil { + return nil, err + } + if !loginAttemptCollectionExists { + _, err = arangodb.CreateCollection(ctx, schemas.Collections.LoginAttempt, nil) + if err != nil { + return nil, err + } + } + loginAttemptCollection, err := arangodb.Collection(ctx, schemas.Collections.LoginAttempt) + if err != nil { + return nil, err + } + loginAttemptCollection.EnsureHashIndex(ctx, []string{"email"}, &arangoDriver.EnsureHashIndexOptions{ + Sparse: true, + }) + loginAttemptCollection.EnsurePersistentIndex(ctx, []string{"attempted_at"}, &arangoDriver.EnsurePersistentIndexOptions{ + Sparse: true, + }) + return &provider{ config: cfg, dependencies: deps, diff --git a/internal/storage/db/cassandradb/login_attempt.go b/internal/storage/db/cassandradb/login_attempt.go new file mode 100644 index 00000000..0c8b5c63 --- /dev/null +++ b/internal/storage/db/cassandradb/login_attempt.go @@ -0,0 +1,81 @@ +package cassandradb + +import ( + "context" + "fmt" + "time" + + "github.com/gocql/gocql" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + insertQuery := fmt.Sprintf( + "INSERT INTO %s (id, email, ip_address, successful, attempted_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", + KeySpace+"."+schemas.Collections.LoginAttempt, + ) + return p.db.Query(insertQuery, + loginAttempt.ID, + loginAttempt.Email, + loginAttempt.IPAddress, + loginAttempt.Successful, + loginAttempt.AttemptedAt, + loginAttempt.CreatedAt, + ).Exec() +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + countQuery := fmt.Sprintf( + `SELECT COUNT(*) FROM %s WHERE email = ? AND successful = false AND attempted_at >= ? ALLOW FILTERING`, + KeySpace+"."+schemas.Collections.LoginAttempt, + ) + var count int64 + err := p.db.Query(countQuery, email, since).Consistency(gocql.One).Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +// Cassandra does not support DELETE with a range filter directly, so we first select the IDs and delete one by one. +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + selectQuery := fmt.Sprintf( + `SELECT id FROM %s WHERE attempted_at < ? ALLOW FILTERING`, + KeySpace+"."+schemas.Collections.LoginAttempt, + ) + scanner := p.db.Query(selectQuery, before).Iter().Scanner() + var ids []string + for scanner.Next() { + var id string + if err := scanner.Scan(&id); err != nil { + return err + } + ids = append(ids, id) + } + if err := scanner.Err(); err != nil { + return err + } + deleteQuery := fmt.Sprintf( + `DELETE FROM %s WHERE id = ?`, + KeySpace+"."+schemas.Collections.LoginAttempt, + ) + for _, id := range ids { + if err := p.db.Query(deleteQuery, id).Exec(); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/cassandradb/provider.go b/internal/storage/db/cassandradb/provider.go index d7198122..95e817b3 100644 --- a/internal/storage/db/cassandradb/provider.go +++ b/internal/storage/db/cassandradb/provider.go @@ -345,6 +345,23 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { return nil, err } + // LoginAttempt table and indexes + loginAttemptCollectionQuery := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.%s (id text, email text, ip_address text, successful boolean, attempted_at bigint, created_at bigint, PRIMARY KEY (id))", KeySpace, schemas.Collections.LoginAttempt) + err = session.Query(loginAttemptCollectionQuery).Exec() + if err != nil { + return nil, err + } + loginAttemptEmailIndex := fmt.Sprintf("CREATE INDEX IF NOT EXISTS authorizer_login_attempt_email ON %s.%s (email)", KeySpace, schemas.Collections.LoginAttempt) + err = session.Query(loginAttemptEmailIndex).Exec() + if err != nil { + return nil, err + } + loginAttemptAttemptedAtIndex := fmt.Sprintf("CREATE INDEX IF NOT EXISTS authorizer_login_attempt_attempted_at ON %s.%s (attempted_at)", KeySpace, schemas.Collections.LoginAttempt) + err = session.Query(loginAttemptAttemptedAtIndex).Exec() + if err != nil { + return nil, err + } + return &provider{ config: cfg, dependencies: deps, diff --git a/internal/storage/db/couchbase/login_attempt.go b/internal/storage/db/couchbase/login_attempt.go new file mode 100644 index 00000000..eb7e29db --- /dev/null +++ b/internal/storage/db/couchbase/login_attempt.go @@ -0,0 +1,78 @@ +package couchbase + +import ( + "context" + "fmt" + "time" + + "github.com/couchbase/gocb/v2" + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + insertOpt := gocb.InsertOptions{ + Context: ctx, + } + _, err := p.db.Collection(schemas.Collections.LoginAttempt).Insert(loginAttempt.ID, loginAttempt, &insertOpt) + return err +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + query := fmt.Sprintf( + `SELECT COUNT(*) as count FROM %s.%s WHERE email = $email AND successful = false AND attempted_at >= $since`, + p.scopeName, schemas.Collections.LoginAttempt, + ) + params := map[string]interface{}{ + "email": email, + "since": since, + } + queryResult, err := p.db.Query(query, &gocb.QueryOptions{ + Context: ctx, + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + NamedParameters: params, + }) + if err != nil { + return 0, err + } + var result struct { + Count int64 `json:"count"` + } + if queryResult.Next() { + if err := queryResult.Row(&result); err != nil { + return 0, err + } + } + if err := queryResult.Err(); err != nil { + return 0, err + } + return result.Count, nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + query := fmt.Sprintf( + `DELETE FROM %s.%s WHERE attempted_at < $before`, + p.scopeName, schemas.Collections.LoginAttempt, + ) + params := map[string]interface{}{ + "before": before, + } + _, err := p.db.Query(query, &gocb.QueryOptions{ + Context: ctx, + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + NamedParameters: params, + }) + return err +} diff --git a/internal/storage/db/couchbase/provider.go b/internal/storage/db/couchbase/provider.go index 85aee384..987b2d43 100644 --- a/internal/storage/db/couchbase/provider.go +++ b/internal/storage/db/couchbase/provider.go @@ -220,5 +220,10 @@ func getIndex(scopeName string) map[string][]string { oauthStateIndex1 := fmt.Sprintf("CREATE INDEX OAuthStateKeyIndex ON %s.%s(state_key)", scopeName, schemas.Collections.OAuthState) indices[schemas.Collections.OAuthState] = []string{oauthStateIndex1} + // LoginAttempt indexes + loginAttemptIndex1 := fmt.Sprintf("CREATE INDEX LoginAttemptEmailIndex ON %s.%s(email)", scopeName, schemas.Collections.LoginAttempt) + loginAttemptIndex2 := fmt.Sprintf("CREATE INDEX LoginAttemptAttemptedAtIndex ON %s.%s(attempted_at)", scopeName, schemas.Collections.LoginAttempt) + indices[schemas.Collections.LoginAttempt] = []string{loginAttemptIndex1, loginAttemptIndex2} + return indices } diff --git a/internal/storage/db/dynamodb/login_attempt.go b/internal/storage/db/dynamodb/login_attempt.go new file mode 100644 index 00000000..f857b37e --- /dev/null +++ b/internal/storage/db/dynamodb/login_attempt.go @@ -0,0 +1,55 @@ +package dynamodb + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + collection := p.db.Table(schemas.Collections.LoginAttempt) + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + return collection.Put(loginAttempt).RunWithContext(ctx) +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + collection := p.db.Table(schemas.Collections.LoginAttempt) + var loginAttempts []*schemas.LoginAttempt + err := collection.Scan(). + Filter("'email' = ? AND 'successful' = ? AND 'attempted_at' >= ?", email, false, since). + AllWithContext(ctx, &loginAttempts) + if err != nil { + return 0, err + } + return int64(len(loginAttempts)), nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + collection := p.db.Table(schemas.Collections.LoginAttempt) + var loginAttempts []*schemas.LoginAttempt + err := collection.Scan(). + Filter("'attempted_at' < ?", before). + AllWithContext(ctx, &loginAttempts) + if err != nil { + return err + } + for _, la := range loginAttempts { + if err := collection.Delete("id", la.ID).RunWithContext(ctx); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/dynamodb/provider.go b/internal/storage/db/dynamodb/provider.go index 88fae327..47fb920f 100644 --- a/internal/storage/db/dynamodb/provider.go +++ b/internal/storage/db/dynamodb/provider.go @@ -62,6 +62,7 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { db.CreateTable(schemas.Collections.SessionToken, schemas.SessionToken{}).Wait() db.CreateTable(schemas.Collections.MFASession, schemas.MFASession{}).Wait() db.CreateTable(schemas.Collections.OAuthState, schemas.OAuthState{}).Wait() + db.CreateTable(schemas.Collections.LoginAttempt, schemas.LoginAttempt{}).Wait() return &provider{ db: db, config: cfg, diff --git a/internal/storage/db/mongodb/login_attempt.go b/internal/storage/db/mongodb/login_attempt.go new file mode 100644 index 00000000..65d92e29 --- /dev/null +++ b/internal/storage/db/mongodb/login_attempt.go @@ -0,0 +1,52 @@ +package mongodb + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + loginAttemptCollection := p.db.Collection(schemas.Collections.LoginAttempt, options.Collection()) + _, err := loginAttemptCollection.InsertOne(ctx, loginAttempt) + return err +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + loginAttemptCollection := p.db.Collection(schemas.Collections.LoginAttempt, options.Collection()) + query := bson.M{ + "email": email, + "successful": false, + "attempted_at": bson.M{"$gte": since}, + } + count, err := loginAttemptCollection.CountDocuments(ctx, query, options.Count()) + if err != nil { + return 0, err + } + return count, nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + loginAttemptCollection := p.db.Collection(schemas.Collections.LoginAttempt, options.Collection()) + query := bson.M{ + "attempted_at": bson.M{"$lt": before}, + } + _, err := loginAttemptCollection.DeleteMany(ctx, query) + return err +} diff --git a/internal/storage/db/mongodb/provider.go b/internal/storage/db/mongodb/provider.go index e8ecfdd2..4ae33b0e 100644 --- a/internal/storage/db/mongodb/provider.go +++ b/internal/storage/db/mongodb/provider.go @@ -178,6 +178,20 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { }, }, options.CreateIndexes()) + // LoginAttempt collection and indexes + mongodb.CreateCollection(ctx, schemas.Collections.LoginAttempt, options.CreateCollection()) + loginAttemptCollection := mongodb.Collection(schemas.Collections.LoginAttempt, options.Collection()) + loginAttemptCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.M{"email": 1}, + Options: options.Index().SetSparse(true), + }, + { + Keys: bson.M{"attempted_at": 1}, + Options: options.Index().SetSparse(true), + }, + }, options.CreateIndexes()) + return &provider{ config: config, dependencies: deps, diff --git a/internal/storage/db/sql/login_attempt.go b/internal/storage/db/sql/login_attempt.go new file mode 100644 index 00000000..3c75199e --- /dev/null +++ b/internal/storage/db/sql/login_attempt.go @@ -0,0 +1,46 @@ +package sql + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm/clause" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddLoginAttempt records a login attempt in the database +func (p *provider) AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error { + if loginAttempt.ID == "" { + loginAttempt.ID = uuid.New().String() + } + loginAttempt.Key = loginAttempt.ID + loginAttempt.CreatedAt = time.Now().Unix() + if loginAttempt.AttemptedAt == 0 { + loginAttempt.AttemptedAt = loginAttempt.CreatedAt + } + res := p.db.Clauses( + clause.OnConflict{ + DoNothing: true, + }).Create(&loginAttempt) + return res.Error +} + +// CountFailedLoginAttemptsSince counts failed login attempts for an email since the given Unix timestamp +func (p *provider) CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) { + var count int64 + res := p.db.Model(&schemas.LoginAttempt{}). + Where("email = ? AND successful = ? AND attempted_at >= ?", email, false, since). + Count(&count) + if res.Error != nil { + return 0, res.Error + } + return count, nil +} + +// DeleteLoginAttemptsBefore removes all login attempts older than the given Unix timestamp +func (p *provider) DeleteLoginAttemptsBefore(ctx context.Context, before int64) error { + res := p.db.Where("attempted_at < ?", before).Delete(&schemas.LoginAttempt{}) + return res.Error +} diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go index 2a7dbe1f..2e58f174 100644 --- a/internal/storage/db/sql/provider.go +++ b/internal/storage/db/sql/provider.go @@ -83,7 +83,7 @@ func NewProvider( } } - err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}) + err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.LoginAttempt{}) if err != nil { return nil, err } diff --git a/internal/storage/provider.go b/internal/storage/provider.go index afb7643c..482e33f3 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -160,6 +160,13 @@ type Provider interface { DeleteOAuthStateByKey(ctx context.Context, key string) error // GetAllOAuthStates retrieves all OAuth states (for testing) GetAllOAuthStates(ctx context.Context) ([]*schemas.OAuthState, error) + + // AddLoginAttempt records a login attempt + AddLoginAttempt(ctx context.Context, loginAttempt *schemas.LoginAttempt) error + // CountFailedLoginAttemptsSince counts failed attempts since a timestamp for an email + CountFailedLoginAttemptsSince(ctx context.Context, email string, since int64) (int64, error) + // DeleteLoginAttemptsBefore removes login attempts older than a timestamp + DeleteLoginAttemptsBefore(ctx context.Context, before int64) error } // New creates a new database provider based on the configuration diff --git a/internal/storage/schemas/login_attempt.go b/internal/storage/schemas/login_attempt.go new file mode 100644 index 00000000..57ebc718 --- /dev/null +++ b/internal/storage/schemas/login_attempt.go @@ -0,0 +1,14 @@ +package schemas + +// Note: any change here should be reflected in providers/casandra/provider.go as it does not have model support in collection creation + +// LoginAttempt tracks individual login attempts for sliding window rate limiting +type LoginAttempt struct { + Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty" dynamo:"key,omitempty"` // for arangodb + ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id" dynamo:"id,hash"` + Email string `gorm:"type:varchar(256);index" json:"email" bson:"email" cql:"email" dynamo:"email"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address" bson:"ip_address" cql:"ip_address" dynamo:"ip_address"` + Successful bool `json:"successful" bson:"successful" cql:"successful" dynamo:"successful"` + AttemptedAt int64 `gorm:"index" json:"attempted_at" bson:"attempted_at" cql:"attempted_at" dynamo:"attempted_at"` + CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` +} diff --git a/internal/storage/schemas/model.go b/internal/storage/schemas/model.go index 70d55123..544cedec 100644 --- a/internal/storage/schemas/model.go +++ b/internal/storage/schemas/model.go @@ -15,6 +15,7 @@ type CollectionList struct { SessionToken string MFASession string OAuthState string + LoginAttempt string } var ( @@ -35,5 +36,6 @@ var ( SessionToken: Prefix + "session_tokens", MFASession: Prefix + "mfa_sessions", OAuthState: Prefix + "oauth_states", + LoginAttempt: Prefix + "login_attempts", } )