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", } )