Bezbednost: superadmin može da odblokira IP adrese zaključane bruteforce zaštitom

Nova stranica /admin/blokirane-ip — lista IP adresa sa previše neuspelih pokušaja
prijave (broj neuspeha, poslednji pokušaj, preostalo vreme zaključavanja) i dugme
za ručno odblokiranje. Nova dozvola 'bezbednost.odblokiraj', namerno rezervisana
isključivo za ulogu superadmin (admin je nema — odblokiranje zaobilazi bruteforce
zaštitu). Repo metode PokusajiPrijaveRepo.ListaBlokiranih/Odblokiraj.
This commit is contained in:
2026-07-02 21:24:25 +02:00
parent b35b41b82e
commit 85a2ee1b64
9 changed files with 208 additions and 4 deletions
+4
View File
@@ -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
+50
View File
@@ -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
}
+81
View File
@@ -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)
}
+1 -1
View File
@@ -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",
+3
View File
@@ -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
+7
View File
@@ -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
}