Merge pull request 'Add account lockout after failed login attempts' (#48) from fix/account-lockout into main
Reviewed-on: https://git.ts.mattnite.net/mattnite/forgejo-tickets/pulls/48
This commit is contained in:
commit
2b2f7b84f0
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattnite/forgejo-tickets/internal/email"
|
"github.com/mattnite/forgejo-tickets/internal/email"
|
||||||
|
|
@ -12,10 +14,21 @@ import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLoginAttempts = 5
|
||||||
|
lockoutDuration = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginAttempt struct {
|
||||||
|
count int
|
||||||
|
lastFail time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
store *PGStore
|
store *PGStore
|
||||||
email *email.Client
|
email *email.Client
|
||||||
|
loginAttempts sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service {
|
func NewService(db *gorm.DB, store *PGStore, emailClient *email.Client) *Service {
|
||||||
|
|
@ -48,8 +61,21 @@ func (s *Service) Register(ctx context.Context, emailAddr, password, name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) {
|
func (s *Service) Login(ctx context.Context, emailAddr, password string) (*models.User, error) {
|
||||||
|
// Check if the account is locked due to too many failed attempts
|
||||||
|
if val, ok := s.loginAttempts.Load(emailAddr); ok {
|
||||||
|
attempt := val.(*loginAttempt)
|
||||||
|
if attempt.count >= maxLoginAttempts && time.Since(attempt.lastFail) < lockoutDuration {
|
||||||
|
return nil, fmt.Errorf("account temporarily locked, try again later")
|
||||||
|
}
|
||||||
|
// Reset if the lockout window has expired
|
||||||
|
if time.Since(attempt.lastFail) >= lockoutDuration {
|
||||||
|
s.loginAttempts.Delete(emailAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
if err := s.db.WithContext(ctx).Where("email = ?", emailAddr).First(&user).Error; err != nil {
|
||||||
|
s.recordFailedAttempt(emailAddr)
|
||||||
return nil, fmt.Errorf("invalid email or password")
|
return nil, fmt.Errorf("invalid email or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +84,7 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
|
||||||
|
s.recordFailedAttempt(emailAddr)
|
||||||
return nil, fmt.Errorf("invalid email or password")
|
return nil, fmt.Errorf("invalid email or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,9 +96,19 @@ func (s *Service) Login(ctx context.Context, emailAddr, password string) (*model
|
||||||
return nil, fmt.Errorf("your account is pending admin approval")
|
return nil, fmt.Errorf("your account is pending admin approval")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear failed attempts on successful login
|
||||||
|
s.loginAttempts.Delete(emailAddr)
|
||||||
|
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordFailedAttempt(emailAddr string) {
|
||||||
|
val, _ := s.loginAttempts.LoadOrStore(emailAddr, &loginAttempt{})
|
||||||
|
attempt := val.(*loginAttempt)
|
||||||
|
attempt.count++
|
||||||
|
attempt.lastFail = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error {
|
func (s *Service) CreateSession(r *http.Request, w http.ResponseWriter, userID uuid.UUID) error {
|
||||||
session, err := s.store.Get(r, sessionCookieName)
|
session, err := s.store.Get(r, sessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue