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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user