Bezbednost — security headers, fail2ban logovanje, bruteforce zaštita, CSRF zaštita

This commit is contained in:
2026-06-03 21:38:16 +02:00
parent 974d76360a
commit ed7ae605b2
15 changed files with 352 additions and 18 deletions
+9 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"fmt"
"io"
"log"
@@ -10,6 +11,7 @@ import (
"sort"
"time"
"ntech/internal/auth"
"ntech/internal/config"
"ntech/internal/db/sqlite"
"ntech/internal/handler"
@@ -25,6 +27,7 @@ var Verzija = "dev"
func main() {
godotenv.Load("ntech.env")
auth.InitAuthLog()
if config.JelPrvoPokretanje() {
config.PokreniSetup()
@@ -54,13 +57,15 @@ func main() {
napraviStartupBackup(putanjaBaze)
// periodično brisanje isteklih sesija
// periodično brisanje isteklih sesija i starih pokušaja prijave
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
sesijeRepo := sqlite.NoviSesijeRepo(db)
pokusajiRepo := sqlite.NoviPokusajiPrijaveRepo(db)
for range ticker.C {
_ = sesijeRepo.ObrisiIstekle(nil)
_ = sesijeRepo.ObrisiIstekle(context.Background())
_ = pokusajiRepo.ObrisiStare(context.Background(), time.Now().Add(-24*time.Hour))
}
}()
@@ -79,6 +84,8 @@ func main() {
}
r := chi.NewRouter()
r.Use(ntechmw.BezbednostHeaders())
r.Use(ntechmw.CsrfMiddleware)
r.Use(middleware.Compress(5))
// statični fajlovi
+52
View File
@@ -0,0 +1,52 @@
package auth
import (
"log"
"os"
"path/filepath"
"time"
)
var authLogger *log.Logger
// InitAuthLog otvara log fajl za auth događaje (pokušaji prijave, zaključavanja)
func InitAuthLog() {
putanje := []string{"/var/log/ntech/auth.log", "./logs/auth.log"}
for _, p := range putanje {
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
continue
}
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
continue
}
authLogger = log.New(f, "", 0)
log.Printf("Auth log: %s", p)
return
}
// fallback — logujemo u standardni izlaz
authLogger = log.New(os.Stdout, "", 0)
}
func zapisiAuth(nivo, ip, korisnik, razlog string) {
if authLogger == nil {
return
}
authLogger.Printf("%s %s user=%s ip=%s reason=%s",
time.Now().Format("2006-01-02 15:04:05"), nivo, korisnik, ip, razlog)
}
// LogNeuspehPrijave beleži neuspeli pokušaj prijave u fail2ban-kompatibilnom formatu
func LogNeuspehPrijave(ip, korisnik, razlog string) {
zapisiAuth("[FAILED]", ip, korisnik, razlog)
}
// LogUspehPrijave beleži uspešnu prijavu
func LogUspehPrijave(ip, korisnik string) {
zapisiAuth("[SUCCESS]", ip, korisnik, "ok")
}
// LogZaklucano beleži pokušaj prijave sa zaključanog IP-a
func LogZaklucano(ip, korisnik string) {
zapisiAuth("[LOCKED]", ip, korisnik, "too_many_attempts")
}
+8
View File
@@ -103,6 +103,14 @@ type PodsetnikFilter struct {
SamoAktivni bool // true = samo nezavršeni; false = svi
}
// PokusajiPrijaveRepository definiše operacije nad evidencijom pokušaja prijave
type PokusajiPrijaveRepository interface {
Zabeleži(ctx context.Context, ip, korisnickoIme string, uspeh bool) 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)
ObrisiStare(ctx context.Context, pre time.Time) error
}
// PodsetnikRepository definiše operacije nad podsetnicima
type PodsetnikRepository interface {
Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error)
+79
View File
@@ -0,0 +1,79 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
)
// PokusajiPrijaveRepo implementira db.PokusajiPrijaveRepository nad SQLite
type PokusajiPrijaveRepo struct {
db *sql.DB
}
// NoviPokusajiPrijaveRepo kreira novi repozitorijum za praćenje pokušaja prijave
func NoviPokusajiPrijaveRepo(db *sql.DB) *PokusajiPrijaveRepo {
return &PokusajiPrijaveRepo{db: db}
}
// Zabeleži upisuje novi pokušaj prijave (uspešan ili neuspešan)
func (r *PokusajiPrijaveRepo) Zabeleži(ctx context.Context, ip, korisnickoIme string, uspeh bool) error {
u := 0
if uspeh {
u = 1
}
_, err := r.db.ExecContext(ctx,
"INSERT INTO pokusaji_prijave (ip, korisnicko_ime, uspeh) VALUES (?, ?, ?)",
ip, korisnickoIme, u,
)
if err != nil {
return fmt.Errorf("ntech: PokusajiPrijaveRepo.Zabeleži: %w", err)
}
return nil
}
// BrojNeuspeha vraća broj neuspelih pokušaja za datu IP adresu od zadatog trenutka
func (r *PokusajiPrijaveRepo) BrojNeuspeha(ctx context.Context, ip string, od time.Time) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM pokusaji_prijave WHERE ip = ? AND uspeh = 0 AND vreme > ?",
ip, od.UTC().Format("2006-01-02 15:04:05"),
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("ntech: PokusajiPrijaveRepo.BrojNeuspeha: %w", err)
}
return n, nil
}
// VremePoslednjeg vraća vreme poslednjeg neuspelog pokušaja za datu IP adresu od zadatog trenutka
func (r *PokusajiPrijaveRepo) VremePoslednjeg(ctx context.Context, ip string, od time.Time) (time.Time, bool, error) {
var s sql.NullString
err := r.db.QueryRowContext(ctx,
"SELECT MAX(vreme) FROM pokusaji_prijave WHERE ip = ? AND uspeh = 0 AND vreme > ?",
ip, od.UTC().Format("2006-01-02 15:04:05"),
).Scan(&s)
if err != nil {
return time.Time{}, false, fmt.Errorf("ntech: PokusajiPrijaveRepo.VremePoslednjeg: %w", err)
}
if !s.Valid || s.String == "" {
return time.Time{}, false, nil
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", s.String, time.UTC)
if err != nil {
return time.Time{}, false, fmt.Errorf("ntech: PokusajiPrijaveRepo.VremePoslednjeg: parse: %w", err)
}
return t, true, nil
}
// ObrisiStare briše sve zapise starije od zadatog trenutka
func (r *PokusajiPrijaveRepo) ObrisiStare(ctx context.Context, pre time.Time) error {
_, err := r.db.ExecContext(ctx,
"DELETE FROM pokusaji_prijave WHERE vreme < ?",
pre.UTC().Format("2006-01-02 15:04:05"),
)
if err != nil {
return fmt.Errorf("ntech: PokusajiPrijaveRepo.ObrisiStare: %w", err)
}
return nil
}
+11 -8
View File
@@ -13,17 +13,18 @@ import (
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
DB *sql.DB
Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetniciFRepo db.PodsetnikRepository
PokusajiRepo db.PokusajiPrijaveRepository
Verzija string
Templates map[string]*template.Template
}
@@ -42,6 +43,7 @@ func Novi(baza *sql.DB) *Handler {
KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetniciFRepo: sqlite.NoviPodsetnikRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
}
}
@@ -60,5 +62,6 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
ps.KorisnikIme = k.KorisnickoIme
ps.KorisnikUloga = k.Uloga
}
ps.CsrfToken = middleware.CsrfToken(r.Context())
return ps
}
+65 -7
View File
@@ -1,19 +1,26 @@
package handler
import (
"context"
"fmt"
"net"
"net/http"
"time"
"ntech/internal/auth"
"ntech/internal/middleware"
)
const imeKolacica = "ntech_sesija"
const trajanjeSeije = 7 * 24 * time.Hour
const trajanjePredSeije = 5 * time.Minute
const maxNeuspehaPrijave = 5
const prozorPrijave = 15 * time.Minute
const trajanjeZakljucavanja = 30 * time.Minute
// PrikazPrijave renderuje formu za prijavu
func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
// ako nema korisnika, preusmeri na setup wizard
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
if err == nil && !postoji {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
@@ -22,7 +29,8 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "prijava", map[string]any{
"Greska": greska,
"Greska": greska,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
}
@@ -33,24 +41,47 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
return
}
ip := izvuciIP(r.RemoteAddr)
korisnickoIme := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka")
// bruteforce provera — blokira IP posle maxNeuspehaPrijave neuspelih pokušaja
od := time.Now().Add(-prozorPrijave)
n, _ := h.PokusajiRepo.BrojNeuspeha(r.Context(), ip, od)
if n >= maxNeuspehaPrijave {
if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj {
auth.LogZaklucano(ip, korisnickoIme)
h.renderujStandalone(w, "prijava", map[string]any{
"Greska": "zakljucano",
"Preostalo": preostalo,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
return
}
// zaključavanje je isteklo, nastavljamo normalnu obradu
}
korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil || !korisnik.Aktivan {
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user")
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return
}
if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) {
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_password")
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return
}
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, true)
auth.LogUspehPrijave(ip, korisnickoIme)
token := auth.GenerisiToken()
if korisnik.TotpTajna != "" {
// kreira privremenu sesiju koja čeka TOTP verifikaciju
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjePredSeije), false); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return
@@ -60,7 +91,6 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
return
}
// direktna sesija bez TOTP
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjeSeije), true); err != nil {
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return
@@ -84,7 +114,8 @@ func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "totp_provera", map[string]any{
"Greska": greska,
"Greska": greska,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
}
@@ -133,7 +164,8 @@ func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) {
}
greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "setup", map[string]any{
"Greska": greska,
"Greska": greska,
"CsrfToken": middleware.CsrfToken(r.Context()),
})
}
@@ -188,6 +220,33 @@ func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
}
// preostaloBruteforce vraća formatiranu poruku o preostatom vremenu zaključavanja i bool da li je IP još zaključan
func (h *Handler) preostaloBruteforce(ctx context.Context, ip string, od time.Time) (string, bool) {
vreme, ok, _ := h.PokusajiRepo.VremePoslednjeg(ctx, ip, od)
if !ok {
return "", false
}
d := time.Until(vreme.Add(trajanjeZakljucavanja)).Round(time.Second)
if d <= 0 {
return "", false
}
min := int(d.Minutes())
sek := int(d.Seconds()) % 60
if min > 0 {
return fmt.Sprintf("%d min %d sek", min, sek), true
}
return fmt.Sprintf("%d sek", sek), true
}
// izvuciIP izdvaja IP adresu iz RemoteAddr formata "ip:port"
func izvuciIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{
Name: imeKolacica,
@@ -198,4 +257,3 @@ func napraviKolacic(token string, istice time.Time) *http.Cookie {
SameSite: http.SameSiteStrictMode,
}
}
+24
View File
@@ -0,0 +1,24 @@
package middleware
import "net/http"
// BezbednostHeaders dodaje standardne HTTP security headere na svaki odgovor
func BezbednostHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; "+
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdn.jsdelivr.net; "+
"img-src 'self' data: blob:; "+
"font-src 'self'; "+
"connect-src 'self'")
next.ServeHTTP(w, r)
})
}
}
+69
View File
@@ -0,0 +1,69 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/base64"
"net/http"
)
const csrfKolacic = "ntech_csrf"
type csrfKljucTip struct{}
var csrfKljuc = csrfKljucTip{}
// CsrfToken vraća CSRF token iz konteksta zahteva (postavlja ga CsrfMiddleware)
func CsrfToken(ctx context.Context) string {
if v, ok := ctx.Value(csrfKljuc).(string); ok {
return v
}
return ""
}
// CsrfMiddleware generiše i validira CSRF tokene metodom cookie + skriveno polje
func CsrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// čita postojeći ili generiše novi token
token := ""
if k, err := r.Cookie(csrfKolacic); err == nil && k.Value != "" {
token = k.Value
} else {
b := make([]byte, 32)
if _, err := rand.Read(b); err == nil {
token = base64.RawURLEncoding.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: csrfKolacic,
Value: token,
Path: "/",
MaxAge: 86400 * 30,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
}
// ubacujemo token u context radi dostupnosti u handlerima i šablonima
ctx := context.WithValue(r.Context(), csrfKljuc, token)
r = r.WithContext(ctx)
// validiramo na svim mutabilnim HTTP metodama
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
// čitamo token iz tela forme ili zaglavlja (za AJAX)
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if token == "" || submitted != token {
http.Error(w,
"Neispravan sigurnosni token. Osvežite stranicu i pokušajte ponovo.",
http.StatusForbidden,
)
return
}
}
next.ServeHTTP(w, r)
})
}
+2 -1
View File
@@ -30,11 +30,12 @@ type PodaciStranice struct {
Tema string
NazivFirme string
Podnazlov string
LogoTip string // "ikonica", "tekst", "slika"
LogoTip string // "sa_nazivom", "bez_naziva", "slika"
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
Korisnik string
KorisnikIme string // korisničko ime prijavljenog korisnika
KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
CsrfToken string // CSRF zaštitni token za forme
}
// PodaciDashboarda su podaci specifični za dashboard stranicu
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS pokusaji_prijave (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
korisnicko_ime TEXT NOT NULL DEFAULT '',
uspeh INTEGER NOT NULL DEFAULT 0,
vreme DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pokusaji_ip_vreme ON pokusaji_prijave(ip, vreme);
@@ -102,6 +102,7 @@
params.append('naziv', this.modalNaziv.trim());
if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID);
if (this.modalCena) params.append('prodajna_cena', this.modalCena);
params.append('_csrf', document.querySelector('meta[name="csrf-token"]')?.content || '');
try {
const odgovor = await fetch('/magacin/novi', {
+6
View File
@@ -110,6 +110,11 @@
<div class="greska">Pogrešno korisničko ime ili lozinka.</div>
{{else if eq .Greska "2"}}
<div class="greska">Greška na serveru. Pokušajte ponovo.</div>
{{else if eq .Greska "zakljucano"}}
<div class="greska">
Previše neuspelih pokušaja prijave. IP adresa je privremeno blokirana.
{{if .Preostalo}}<br>Pokušajte ponovo za: <strong>{{.Preostalo}}</strong>{{end}}
</div>
{{end}}
{{if .Sacuvano}}
@@ -117,6 +122,7 @@
{{end}}
<form method="POST" action="/prijava">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje">
<label for="korisnicko_ime">Korisničko ime</label>
<input type="text" id="korisnicko_ime" name="korisnicko_ime"
+1
View File
@@ -89,6 +89,7 @@
{{end}}
<form method="POST" action="/setup">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje">
<label for="korisnicko_ime">Korisničko ime</label>
<input type="text" id="korisnicko_ime" name="korisnicko_ime"
+1
View File
@@ -91,6 +91,7 @@
{{end}}
<form method="POST" action="/prijava/totp">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje">
<label for="kod">Kod za verifikaciju</label>
<input type="text" id="kod" name="kod"
@@ -5,6 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{block "naslov" .}}NTech{{end}}</title>
<meta name="csrf-token" content="{{.CsrfToken}}">
<!-- tema — učitava se prva -->
<link rel="stylesheet" href="/static/css/teme/{{.Tema}}.css" />
@@ -82,6 +83,20 @@
</script>
{{block "dodatni-js" .}}{{end}}
<!-- CSRF: automatski dodaje skriveno polje u sve POST forme -->
<script>
document.addEventListener('DOMContentLoaded', function() {
var m = document.querySelector('meta[name="csrf-token"]');
if (!m || !m.content) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (f.querySelector('input[name="_csrf"]')) return;
var i = document.createElement('input');
i.type = 'hidden'; i.name = '_csrf'; i.value = m.content;
f.appendChild(i);
});
});
</script>
</body>
</html>
{{end}}