diff --git a/cmd/http/main.go b/cmd/http/main.go index 1e040b9..1d651c2 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -9,6 +9,7 @@ import ( "syscall" "gitea.urkob.com/urko/prosody-password/internal/api" + "gitea.urkob.com/urko/prosody-password/internal/services/fail2ban" "gitea.urkob.com/urko/prosody-password/internal/services/prosody" "gitea.urkob.com/urko/prosody-password/kit/config" ) @@ -23,7 +24,7 @@ func main() { ctx, cancel := context.WithCancel(signalContext(context.Background())) defer cancel() - restServer := api.NewRestServer(prosody.NewProsody(cfg.Domain)) + restServer := api.NewRestServer(prosody.NewProsody(cfg.Domain), fail2ban.NewFail2Ban()) go func() { if err := restServer.Start(cfg.ApiPort, cfg.Views); err != nil { diff --git a/internal/api/handler/prosody_hdl.go b/internal/api/handler/prosody_hdl.go index af0a1e0..76102c6 100644 --- a/internal/api/handler/prosody_hdl.go +++ b/internal/api/handler/prosody_hdl.go @@ -3,18 +3,21 @@ package handler import ( "fmt" + "gitea.urkob.com/urko/prosody-password/internal/services/fail2ban" "gitea.urkob.com/urko/prosody-password/internal/services/prosody" "github.com/gofiber/fiber/v2" ) -func NewProsodyHandler(prosodyService *prosody.Prosody) ProsodyHandler { - return ProsodyHandler{ - prosodyService: prosodyService, - } -} - type ProsodyHandler struct { prosodyService *prosody.Prosody + fail2banSrv *fail2ban.Fail2Ban +} + +func NewProsodyHandler(prosodyService *prosody.Prosody, fail2banSrv *fail2ban.Fail2Ban) ProsodyHandler { + return ProsodyHandler{ + prosodyService: prosodyService, + fail2banSrv: fail2banSrv, + } } type changePasswordReq struct { @@ -30,6 +33,9 @@ func (handler ProsodyHandler) Post(c *fiber.Ctx) error { } if err := handler.prosodyService.ChangePassword(req.User, req.CurrentPassword, req.NewPassword); err != nil { + for _, ip := range c.IPs() { + handler.fail2banSrv.FailedAttempt(ip) + } return RenderError(c, fmt.Errorf("ChangePassword: %w", err), defaultErrMessage) } diff --git a/internal/api/server.go b/internal/api/server.go index e5c5daf..2059c99 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,9 +1,11 @@ package api import ( + "fmt" "log" "gitea.urkob.com/urko/prosody-password/internal/api/handler" + "gitea.urkob.com/urko/prosody-password/internal/services/fail2ban" "gitea.urkob.com/urko/prosody-password/internal/services/prosody" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" @@ -13,13 +15,16 @@ import ( type RestServer struct { app *fiber.App prosodyService *prosody.Prosody + fail2banSrv *fail2ban.Fail2Ban } func NewRestServer( prosodyService *prosody.Prosody, + fail2banSrv *fail2ban.Fail2Ban, ) *RestServer { return &RestServer{ prosodyService: prosodyService, + fail2banSrv: fail2banSrv, } } @@ -39,8 +44,14 @@ func (s *RestServer) Start(apiPort, views string) error { s.loadViews() - prosodyHdl := handler.NewProsodyHandler(s.prosodyService) + prosodyHdl := handler.NewProsodyHandler(s.prosodyService, s.fail2banSrv) s.app.Post("/changePassword", func(c *fiber.Ctx) error { + for _, ip := range c.IPs() { + if !s.fail2banSrv.CanChangePassword(ip) { + return handler.RenderError(c, fmt.Errorf("id is empty"), "Too many tries, blocked for 1h") + } + } + return prosodyHdl.Post(c) }) diff --git a/internal/services/fail2ban/fail.go b/internal/services/fail2ban/fail.go new file mode 100644 index 0000000..9279714 --- /dev/null +++ b/internal/services/fail2ban/fail.go @@ -0,0 +1,71 @@ +package fail2ban + +import ( + "sync" + "time" +) + +type Client struct { + FailedAttempts int + BlockedUntil time.Time +} + +type Fail2Ban struct { + mu sync.Mutex + clients map[string]*Client +} + +func NewFail2Ban() *Fail2Ban { + return &Fail2Ban{ + clients: make(map[string]*Client), + mu: sync.Mutex{}, + } +} + +func (f *Fail2Ban) FailedAttempt(ip string) { + f.mu.Lock() + defer f.mu.Unlock() + + client, ok := f.clients[ip] + if !ok { + client = &Client{} + f.clients[ip] = client + } + + client.FailedAttempts++ + if client.FailedAttempts >= 3 { + client.BlockedUntil = time.Now().Add(10 * time.Minute) + client.FailedAttempts = 0 + + // Automatically unblock the client after 1 hour + time.AfterFunc(1*time.Hour, func() { + f.unblockClient(ip) + }) + } +} + +func (f *Fail2Ban) unblockClient(ip string) { + f.mu.Lock() + defer f.mu.Unlock() + + client, ok := f.clients[ip] + if ok { + client.BlockedUntil = time.Time{} + } +} + +func (f *Fail2Ban) CanChangePassword(ip string) bool { + f.mu.Lock() + defer f.mu.Unlock() + + client, ok := f.clients[ip] + if !ok { + return true + } + + if time.Now().Before(client.BlockedUntil) { + return false + } + + return true +}