diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index f033fd2..610d6e9 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -457,6 +457,8 @@ func main() { r.Group(func(r chi.Router) { r.Use(ntechmw.RequireSuperAdmin) r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu) + r.Get("/admin/blokirane-ip", h.AdminBlokiraneIP) + r.Post("/admin/blokirane-ip/odblokiraj", h.AdminOdblokirajIP) }) r.Get("/admin/profil", h.AdminProfil) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) diff --git a/internal/db/repository.go b/internal/db/repository.go index 0f7feb7..6512970 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -336,6 +336,10 @@ type PokusajiPrijaveRepository interface { BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error) VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error) ObrisiStare(ctx context.Context, pre time.Time) error + // ListaBlokiranih vraća IP adrese sa >= prag neuspelih pokušaja od zadatog trenutka + ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error) + // Odblokiraj briše neuspele pokušaje za IP adresu, čime se ona odmah oslobađa blokade + Odblokiraj(ctx context.Context, ip string) error } // LoginIstorijsaRepository definiše operacije nad evidencijom prijava diff --git a/internal/db/sqlite/pokusaji_prijave.go b/internal/db/sqlite/pokusaji_prijave.go index e73454d..ed2bb65 100644 --- a/internal/db/sqlite/pokusaji_prijave.go +++ b/internal/db/sqlite/pokusaji_prijave.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "time" + + "ntech/internal/model" ) // PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite @@ -77,3 +79,51 @@ func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) er } return nil } + +// ListaBlokiranih vraća IP adrese sa najmanje prag neuspelih pokušaja prijave od +// zadatog trenutka, grupisano po IP adresi, poređano od najskorije zaključane. +func (r *PokusajiPrijaveRepo) ListaBlokiranih(ctx context.Context, od time.Time, prag int) ([]model.BlokiranaIP, error) { + redovi, err := r.db.QueryContext(ctx, + `SELECT ip, COUNT(*), MAX(vreme) FROM pokusaji_prijave + WHERE uspeh = 0 AND vreme > ? + GROUP BY ip HAVING COUNT(*) >= ? + ORDER BY MAX(vreme) DESC`, + od.UTC().Format("2006-01-02 15:04:05"), prag, + ) + if err != nil { + return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err) + } + defer redovi.Close() + + var lista []model.BlokiranaIP + for redovi.Next() { + var b model.BlokiranaIP + var poslednji string + if err := redovi.Scan(&b.IP, &b.BrojNeuspeha, &poslednji); err != nil { + return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: scan: %w", err) + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", poslednji, time.UTC) + if err != nil { + return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: parse vremena: %w", err) + } + b.PoslednjiPokusaj = t + lista = append(lista, b) + } + if err := redovi.Err(); err != nil { + return nil, fmt.Errorf("ntech: PokusajiPrijaveRepo.ListaBlokiranih: %w", err) + } + return lista, nil +} + +// Odblokiraj briše sve neuspele pokušaje prijave za datu IP adresu — brojač +// neuspeha odmah pada na nulu, pa IP prestaje da bude zaključana. +func (r *PokusajiPrijaveRepo) Odblokiraj(ctx context.Context, ip string) error { + _, err := r.db.ExecContext(ctx, + "DELETE FROM pokusaji_prijave WHERE ip = ? AND uspeh = 0", + ip, + ) + if err != nil { + return fmt.Errorf("ntech: PokusajiPrijaveRepo.Odblokiraj: %w", err) + } + return nil +} diff --git a/internal/handler/blokirane_ip.go b/internal/handler/blokirane_ip.go new file mode 100644 index 0000000..2cb44e7 --- /dev/null +++ b/internal/handler/blokirane_ip.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + ntechsqlite "ntech/internal/db/sqlite" + "ntech/internal/middleware" + "ntech/internal/model" +) + +// stavkaBlokiraneIP je jedan red na stranici blokiranih IP adresa, sa +// unapred izračunatim preostalim vremenom zaključavanja (ili "" ako je isteklo). +type stavkaBlokiraneIP struct { + model.BlokiranaIP + Preostalo string +} + +type podaciBlokiraneIP struct { + model.PodaciStranice + Lista []stavkaBlokiraneIP +} + +// AdminBlokiraneIP prikazuje IP adrese trenutno zaključane zbog previše neuspelih +// pokušaja prijave, sa mogućnošću ručnog odblokiranja (superadmin). +func (h *Handler) AdminBlokiraneIP(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok { + return + } + podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + if err != nil { + http.Error(w, "Greška pri učitavanju podešavanja", http.StatusInternalServerError) + return + } + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "blokirane-ip" + ps.NaslovStranice = "Blokirane IP adrese" + + od := time.Now().Add(-prozorPrijave) + blokirane, err := h.PokusajiRepo.ListaBlokiranih(r.Context(), od, maxNeuspehaPrijave) + if err != nil { + http.Error(w, "Greška pri učitavanju blokiranih IP adresa", http.StatusInternalServerError) + return + } + + lista := make([]stavkaBlokiraneIP, 0, len(blokirane)) + for _, b := range blokirane { + preostalo, zakljucano := h.preostaloBruteforce(r.Context(), b.IP, od) + if !zakljucano { + continue + } + lista = append(lista, stavkaBlokiraneIP{BlokiranaIP: b, Preostalo: preostalo}) + } + + h.renderujTemplate(w, "blokirane_ip", podaciBlokiraneIP{PodaciStranice: ps, Lista: lista}) +} + +// AdminOdblokirajIP briše neuspele pokušaje prijave za datu IP adresu, čime se ona +// odmah oslobađa bruteforce blokade. +func (h *Handler) AdminOdblokirajIP(w http.ResponseWriter, r *http.Request) { + if _, ok := h.zahtevajDozvolu(w, r, "bezbednost.odblokiraj"); !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "Greška pri čitanju forme", http.StatusBadRequest) + return + } + ip := strings.TrimSpace(r.FormValue("ip")) + if ip == "" { + http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther) + return + } + if err := h.PokusajiRepo.Odblokiraj(r.Context(), ip); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Odblokiranje nije uspelo.") + http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther) + return + } + middleware.SetFlash(w, r, h.DB, "uspeh", "IP adresa "+ip+" je odblokirana.") + http.Redirect(w, r, "/admin/blokirane-ip", http.StatusSeeOther) +} diff --git a/internal/handler/kes.go b/internal/handler/kes.go index 94bbed0..04907c4 100644 --- a/internal/handler/kes.go +++ b/internal/handler/kes.go @@ -19,7 +19,7 @@ var bazniSabloni = []string{ // saSidebar su šabloni koji koriste base layout (sidebar + topbar) var saSidebar = []string{ - "admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", + "admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "blokirane_ip", "dashboard", "dobavljaci", "dobavljac_forma", "izvestaji", "prometni_list", "stanje_zaliha", "popis", diff --git a/internal/middleware/dozvole.go b/internal/middleware/dozvole.go index 7e5462e..9e3f553 100644 --- a/internal/middleware/dozvole.go +++ b/internal/middleware/dozvole.go @@ -48,6 +48,7 @@ var sveAkcije = []string{ "kpo.obrisi", "fiskal.pazar", "fiskal.zakljucenje", + "bezbednost.odblokiraj", } // SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu @@ -114,6 +115,8 @@ func ImaDozvolu(uloga, akcija string) bool { // dnevni fiskalni pazar — administrativno, radnik nema (zaključenje dana je nepovratno) case "fiskal.pazar", "fiskal.zakljucenje": return true + // "bezbednost.odblokiraj" NAMERNO izostavljeno iz admin uloge — odblokiranje IP + // adrese zaobilazi bruteforce zaštitu, pa je rezervisano isključivo za superadmin. } return false diff --git a/internal/model/login_pokusaj.go b/internal/model/login_pokusaj.go index e0dd4d1..9e38d88 100644 --- a/internal/model/login_pokusaj.go +++ b/internal/model/login_pokusaj.go @@ -12,3 +12,10 @@ type LoginPokusaj struct { Razlog string Vreme time.Time } + +// BlokiranaIP predstavlja IP adresu trenutno zaključanu zbog previše neuspelih pokušaja prijave +type BlokiranaIP struct { + IP string + BrojNeuspeha int + PoslednjiPokusaj time.Time +} diff --git a/web/templates/komponente/sidebar.html b/web/templates/komponente/sidebar.html index 3985501..8ca2c60 100644 --- a/web/templates/komponente/sidebar.html +++ b/web/templates/komponente/sidebar.html @@ -215,19 +215,19 @@ {{if index .Dozvole "podesavanja.pregled"}}
- {{end}} diff --git a/web/templates/stranice/blokirane_ip.html b/web/templates/stranice/blokirane_ip.html new file mode 100644 index 0000000..f2980e0 --- /dev/null +++ b/web/templates/stranice/blokirane_ip.html @@ -0,0 +1,50 @@ +{{template "base" .}} {{define "naslov"}}Blokirane IP adrese — NTech{{end}} {{define "sadrzaj"}} +
+
+ Blokirane IP adrese +
+ +
+
+ IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave + Osveži +
+ + {{if .Lista}} +
+ + + + + + + + + + + + {{range .Lista}} + + + + + + + + {{end}} + +
IP adresaNeuspelih pokušajaPoslednji pokušajPreostalo
{{.IP}}{{.BrojNeuspeha}}{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}{{.Preostalo}} +
+ + + +
+
+
+ {{else}} +
Trenutno nema blokiranih IP adresa.
+ {{end}} +
+
+{{end}}