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 package main
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -10,6 +11,7 @@ import (
"sort" "sort"
"time" "time"
"ntech/internal/auth"
"ntech/internal/config" "ntech/internal/config"
"ntech/internal/db/sqlite" "ntech/internal/db/sqlite"
"ntech/internal/handler" "ntech/internal/handler"
@@ -25,6 +27,7 @@ var Verzija = "dev"
func main() { func main() {
godotenv.Load("ntech.env") godotenv.Load("ntech.env")
auth.InitAuthLog()
if config.JelPrvoPokretanje() { if config.JelPrvoPokretanje() {
config.PokreniSetup() config.PokreniSetup()
@@ -54,13 +57,15 @@ func main() {
napraviStartupBackup(putanjaBaze) napraviStartupBackup(putanjaBaze)
// periodično brisanje isteklih sesija // periodično brisanje isteklih sesija i starih pokušaja prijave
go func() { go func() {
ticker := time.NewTicker(time.Hour) ticker := time.NewTicker(time.Hour)
defer ticker.Stop() defer ticker.Stop()
sesijeRepo := sqlite.NoviSesijeRepo(db) sesijeRepo := sqlite.NoviSesijeRepo(db)
pokusajiRepo := sqlite.NoviPokusajiPrijaveRepo(db)
for range ticker.C { 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 := chi.NewRouter()
r.Use(ntechmw.BezbednostHeaders())
r.Use(ntechmw.CsrfMiddleware)
r.Use(middleware.Compress(5)) r.Use(middleware.Compress(5))
// statični fajlovi // 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 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 // PodsetnikRepository definiše operacije nad podsetnicima
type PodsetnikRepository interface { type PodsetnikRepository interface {
Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error) 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 // Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct { type Handler struct {
DB *sql.DB DB *sql.DB
Artikli db.ArtikalRepository Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository ServisRepo db.ServisRepository
ProdajaRepo db.ProdajaRepository ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository SesijeRepo db.SesijeRepository
PodsetniciFRepo db.PodsetnikRepository PodsetniciFRepo db.PodsetnikRepository
PokusajiRepo db.PokusajiPrijaveRepository
Verzija string Verzija string
Templates map[string]*template.Template Templates map[string]*template.Template
} }
@@ -42,6 +43,7 @@ func Novi(baza *sql.DB) *Handler {
KorisniciRepo: sqlite.NoviKorisniciRepo(baza), KorisniciRepo: sqlite.NoviKorisniciRepo(baza),
SesijeRepo: sqlite.NoviSesijeRepo(baza), SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetniciFRepo: sqlite.NoviPodsetnikRepo(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.KorisnikIme = k.KorisnickoIme
ps.KorisnikUloga = k.Uloga ps.KorisnikUloga = k.Uloga
} }
ps.CsrfToken = middleware.CsrfToken(r.Context())
return ps return ps
} }
+65 -7
View File
@@ -1,19 +1,26 @@
package handler package handler
import ( import (
"context"
"fmt"
"net"
"net/http" "net/http"
"time" "time"
"ntech/internal/auth" "ntech/internal/auth"
"ntech/internal/middleware"
) )
const imeKolacica = "ntech_sesija" const imeKolacica = "ntech_sesija"
const trajanjeSeije = 7 * 24 * time.Hour const trajanjeSeije = 7 * 24 * time.Hour
const trajanjePredSeije = 5 * time.Minute const trajanjePredSeije = 5 * time.Minute
const maxNeuspehaPrijave = 5
const prozorPrijave = 15 * time.Minute
const trajanjeZakljucavanja = 30 * time.Minute
// PrikazPrijave renderuje formu za prijavu // PrikazPrijave renderuje formu za prijavu
func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) { func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
// ako nema korisnika, preusmeri na setup wizard
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context()) postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
if err == nil && !postoji { if err == nil && !postoji {
http.Redirect(w, r, "/setup", http.StatusSeeOther) 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") greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "prijava", map[string]any{ 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 return
} }
ip := izvuciIP(r.RemoteAddr)
korisnickoIme := r.FormValue("korisnicko_ime") korisnickoIme := r.FormValue("korisnicko_ime")
lozinka := r.FormValue("lozinka") 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) korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme)
if err != nil || !korisnik.Aktivan { 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) http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return return
} }
if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) { 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) http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
return return
} }
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, true)
auth.LogUspehPrijave(ip, korisnickoIme)
token := auth.GenerisiToken() token := auth.GenerisiToken()
if korisnik.TotpTajna != "" { 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 { 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) http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return return
@@ -60,7 +91,6 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
return return
} }
// direktna sesija bez TOTP
if err := h.SesijeRepo.Kreiraj(r.Context(), korisnik.ID, token, time.Now().Add(trajanjeSeije), true); err != nil { 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) http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
return return
@@ -84,7 +114,8 @@ func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
greska := r.URL.Query().Get("greska") greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "totp_provera", map[string]any{ 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") greska := r.URL.Query().Get("greska")
h.renderujStandalone(w, "setup", map[string]any{ 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) 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 { func napraviKolacic(token string, istice time.Time) *http.Cookie {
return &http.Cookie{ return &http.Cookie{
Name: imeKolacica, Name: imeKolacica,
@@ -198,4 +257,3 @@ func napraviKolacic(token string, istice time.Time) *http.Cookie {
SameSite: http.SameSiteStrictMode, 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 Tema string
NazivFirme string NazivFirme string
Podnazlov 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" LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
Korisnik string Korisnik string
KorisnikIme string // korisničko ime prijavljenog korisnika KorisnikIme string // korisničko ime prijavljenog korisnika
KorisnikUloga string // uloga: "superadmin", "admin", "radnik" KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
CsrfToken string // CSRF zaštitni token za forme
} }
// PodaciDashboarda su podaci specifični za dashboard stranicu // 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()); params.append('naziv', this.modalNaziv.trim());
if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID); if (this.modalKategorijaID) params.append('kategorija_id', this.modalKategorijaID);
if (this.modalCena) params.append('prodajna_cena', this.modalCena); if (this.modalCena) params.append('prodajna_cena', this.modalCena);
params.append('_csrf', document.querySelector('meta[name="csrf-token"]')?.content || '');
try { try {
const odgovor = await fetch('/magacin/novi', { const odgovor = await fetch('/magacin/novi', {
+6
View File
@@ -110,6 +110,11 @@
<div class="greska">Pogrešno korisničko ime ili lozinka.</div> <div class="greska">Pogrešno korisničko ime ili lozinka.</div>
{{else if eq .Greska "2"}} {{else if eq .Greska "2"}}
<div class="greska">Greška na serveru. Pokušajte ponovo.</div> <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}} {{end}}
{{if .Sacuvano}} {{if .Sacuvano}}
@@ -117,6 +122,7 @@
{{end}} {{end}}
<form method="POST" action="/prijava"> <form method="POST" action="/prijava">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje"> <div class="polje">
<label for="korisnicko_ime">Korisničko ime</label> <label for="korisnicko_ime">Korisničko ime</label>
<input type="text" id="korisnicko_ime" name="korisnicko_ime" <input type="text" id="korisnicko_ime" name="korisnicko_ime"
+1
View File
@@ -89,6 +89,7 @@
{{end}} {{end}}
<form method="POST" action="/setup"> <form method="POST" action="/setup">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje"> <div class="polje">
<label for="korisnicko_ime">Korisničko ime</label> <label for="korisnicko_ime">Korisničko ime</label>
<input type="text" id="korisnicko_ime" name="korisnicko_ime" <input type="text" id="korisnicko_ime" name="korisnicko_ime"
+1
View File
@@ -91,6 +91,7 @@
{{end}} {{end}}
<form method="POST" action="/prijava/totp"> <form method="POST" action="/prijava/totp">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<div class="polje"> <div class="polje">
<label for="kod">Kod za verifikaciju</label> <label for="kod">Kod za verifikaciju</label>
<input type="text" id="kod" name="kod" <input type="text" id="kod" name="kod"
@@ -5,6 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{block "naslov" .}}NTech{{end}}</title> <title>{{block "naslov" .}}NTech{{end}}</title>
<meta name="csrf-token" content="{{.CsrfToken}}">
<!-- tema — učitava se prva --> <!-- tema — učitava se prva -->
<link rel="stylesheet" href="/static/css/teme/{{.Tema}}.css" /> <link rel="stylesheet" href="/static/css/teme/{{.Tema}}.css" />
@@ -82,6 +83,20 @@
</script> </script>
{{block "dodatni-js" .}}{{end}} {{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> </body>
</html> </html>
{{end}} {{end}}