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
+2
View File
@@ -457,6 +457,8 @@ func main() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(ntechmw.RequireSuperAdmin) r.Use(ntechmw.RequireSuperAdmin)
r.Post("/admin/dozvole/uloga/{id}", h.AdminDozvolePromeniUlogu) 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.Get("/admin/profil", h.AdminProfil)
r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku) r.Post("/admin/profil/lozinka", h.AdminPromeniLozinku)
+4
View File
@@ -336,6 +336,10 @@ type PokusajiPrijaveRepository interface {
BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error) BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error)
VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error) VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error)
ObrisiStare(ctx context.Context, pre time.Time) 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 // LoginIstorijsaRepository definiše operacije nad evidencijom prijava
+50
View File
@@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"time" "time"
"ntech/internal/model"
) )
// PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite // PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite
@@ -77,3 +79,51 @@ func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) er
} }
return nil 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) // saSidebar su šabloni koji koriste base layout (sidebar + topbar)
var saSidebar = []string{ var saSidebar = []string{
"admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "admin_korisnici", "admin_profil", "admin_login_istorija", "admin_dozvole", "blokirane_ip",
"dashboard", "dashboard",
"dobavljaci", "dobavljac_forma", "dobavljaci", "dobavljac_forma",
"izvestaji", "prometni_list", "stanje_zaliha", "popis", "izvestaji", "prometni_list", "stanje_zaliha", "popis",
+3
View File
@@ -48,6 +48,7 @@ var sveAkcije = []string{
"kpo.obrisi", "kpo.obrisi",
"fiskal.pazar", "fiskal.pazar",
"fiskal.zakljucenje", "fiskal.zakljucenje",
"bezbednost.odblokiraj",
} }
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu // 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) // dnevni fiskalni pazar — administrativno, radnik nema (zaključenje dana je nepovratno)
case "fiskal.pazar", "fiskal.zakljucenje": case "fiskal.pazar", "fiskal.zakljucenje":
return true 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 return false
+7
View File
@@ -12,3 +12,10 @@ type LoginPokusaj struct {
Razlog string Razlog string
Vreme time.Time 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
}
+10 -3
View File
@@ -215,19 +215,19 @@
{{if index .Dozvole "podesavanja.pregled"}} {{if index .Dozvole "podesavanja.pregled"}}
<div> <div>
<button type="button" data-podmeni-dugme <button type="button" data-podmeni-dugme
class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}aktivan{{end}}" class="nav-stavka {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}aktivan{{end}}"
style="width:100%;background:none;border:none;cursor:pointer;"> style="width:100%;background:none;border:none;cursor:pointer;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Podešavanja</span> <span>Podešavanja</span>
<span class="nav-strelica"> <span class="nav-strelica">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole")}}rotate(180deg){{else}}rotate(0deg){{end}}"> style="transition:transform 0.2s;transform:{{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}rotate(180deg){{else}}rotate(0deg){{end}}">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</span> </span>
<span class="nav-tooltip">Podešavanja</span> <span class="nav-tooltip">Podešavanja</span>
</button> </button>
<div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole")}}otvoren{{end}}"> <div class="nav-podmeni {{if or (eq .Stranica "podesavanja") (eq .Stranica "podesavanja-opste") (eq .Stranica "podesavanja-izgled") (eq .Stranica "podesavanja-sistem") (eq .Stranica "podesavanja-kalkulacija-pdv") (eq .Stranica "podesavanja-servis") (eq .Stranica "podesavanja-fiskalizacija") (eq .Stranica "dozvole") (eq .Stranica "blokirane-ip")}}otvoren{{end}}">
<a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}"> <a href="/admin/podesavanja/opste" class="nav-stavka nav-podstavka {{if eq .Stranica "podesavanja-opste"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v14"/><path d="M9 21v-5h6v5"/><path d="M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>
<span>Opšte</span> <span>Opšte</span>
@@ -265,6 +265,13 @@
<span class="nav-tooltip">Dozvole</span> <span class="nav-tooltip">Dozvole</span>
</a> </a>
{{end}} {{end}}
{{if index .Dozvole "bezbednost.odblokiraj"}}
<a href="/admin/blokirane-ip" class="nav-stavka nav-podstavka {{if eq .Stranica "blokirane-ip"}}aktivan{{end}}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<span>Blokirane IP adrese</span>
<span class="nav-tooltip">Blokirane IP adrese</span>
</a>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
+50
View File
@@ -0,0 +1,50 @@
{{template "base" .}} {{define "naslov"}}Blokirane IP adrese — NTech{{end}} {{define "sadrzaj"}}
<div class="stranica-stack" style="display: flex; flex-direction: column; gap: 16px">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap">
<span class="naslov-traka" style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">Blokirane IP adrese</span>
</div>
<div class="kartica animiraj" style="padding: 0; overflow: hidden">
<div style="padding: 16px 20px; border-bottom: 0.5px solid var(--ivica); display: flex; align-items: center; justify-content: space-between">
<span style="font-size: 15px; font-weight: 500; color: var(--tekst-glavni)">IP adrese trenutno zaključane zbog previše neuspelih pokušaja prijave</span>
<a href="/admin/blokirane-ip" class="btn-sekundarno" style="font-size: 13px; padding: 8px 16px">Osveži</a>
</div>
{{if .Lista}}
<div style="overflow-x: auto">
<table class="tabela">
<thead>
<tr style="border-bottom: 0.5px solid var(--ivica)">
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">IP adresa</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Neuspelih pokušaja</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni); white-space: nowrap">Poslednji pokušaj</th>
<th style="padding: 10px 20px; text-align: left; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)">Preostalo</th>
<th style="padding: 10px 20px; text-align: right; font-size: 12px; font-weight: 500; color: var(--tekst-sporedni)"></th>
</tr>
</thead>
<tbody>
{{range .Lista}}
<tr class="animiraj" style="border-bottom: 0.5px solid var(--ivica)">
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni); font-family: monospace">{{.IP}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-glavni)">{{.BrojNeuspeha}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni); white-space: nowrap">{{.PoslednjiPokusaj.Format "02.01.2006. 15:04:05"}}</td>
<td style="padding: 10px 20px; font-size: 13px; color: var(--tekst-sporedni)">{{.Preostalo}}</td>
<td style="padding: 10px 20px; text-align: right">
<form method="POST" action="/admin/blokirane-ip/odblokiraj" style="display:inline;" data-full-reload
onsubmit="return confirm('Odblokirati IP adresu {{.IP}}? Ovo odmah briše sve neuspele pokušaje prijave za nju.')">
<input type="hidden" name="_csrf" value="{{$.CsrfToken}}">
<input type="hidden" name="ip" value="{{.IP}}">
<button type="submit" class="btn-sekundarno" style="font-size: 13px; padding: 6px 14px">Odblokiraj</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div style="padding: 40px 20px; text-align: center; color: var(--tekst-sporedni); font-size: 14px">Trenutno nema blokiranih IP adresa.</div>
{{end}}
</div>
</div>
{{end}}