diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 7d2d22f..526fd40 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -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 diff --git a/internal/auth/log.go b/internal/auth/log.go new file mode 100644 index 0000000..ba8b3bd --- /dev/null +++ b/internal/auth/log.go @@ -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") +} diff --git a/internal/db/repository.go b/internal/db/repository.go index 6fcfc5f..2b14de2 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -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) diff --git a/internal/db/sqlite/pokusaji_prijave.go b/internal/db/sqlite/pokusaji_prijave.go new file mode 100644 index 0000000..e73454d --- /dev/null +++ b/internal/db/sqlite/pokusaji_prijave.go @@ -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 +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index fc28c60..c4af689 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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 } diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index 68d20a9..af5ffab 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -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, } } - diff --git a/internal/middleware/bezbednost.go b/internal/middleware/bezbednost.go new file mode 100644 index 0000000..d27b275 --- /dev/null +++ b/internal/middleware/bezbednost.go @@ -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) + }) + } +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..0cd5b3d --- /dev/null +++ b/internal/middleware/csrf.go @@ -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) + }) +} diff --git a/internal/model/stranica.go b/internal/model/stranica.go index 531efbe..9aa0f57 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -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 diff --git a/migrations/018_pokusaji_prijave.sql b/migrations/018_pokusaji_prijave.sql new file mode 100644 index 0000000..ef9fc66 --- /dev/null +++ b/migrations/018_pokusaji_prijave.sql @@ -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); diff --git a/web/templates/stranice/nabavka_forma.html b/web/templates/stranice/nabavka_forma.html index bbdd74a..8380a27 100644 --- a/web/templates/stranice/nabavka_forma.html +++ b/web/templates/stranice/nabavka_forma.html @@ -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', { diff --git a/web/templates/stranice/prijava.html b/web/templates/stranice/prijava.html index d0d912c..abcf2f5 100644 --- a/web/templates/stranice/prijava.html +++ b/web/templates/stranice/prijava.html @@ -110,6 +110,11 @@