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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -215,19 +215,19 @@
|
||||
{{if index .Dozvole "podesavanja.pregled"}}
|
||||
<div>
|
||||
<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;">
|
||||
<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 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"
|
||||
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"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-tooltip">Podešavanja</span>
|
||||
</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}}">
|
||||
<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>
|
||||
@@ -265,6 +265,13 @@
|
||||
<span class="nav-tooltip">Dozvole</span>
|
||||
</a>
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user