1068bb12e0
- CSRF middleware postavlja MaxBytesReader (6 MB) za multipart pre parsiranja — pojedinačni upload handleri nisu mogli da ograniče veličinu jer čitanje _csrf polja već parsira celo telo - prijava: dummy bcrypt poređenje kada korisnik ne postoji, da vreme odgovora bude isto kao kod postojećeg korisnika (sprečava enumeraciju imena) - podesavanja: strings.Split(...)[0] zamenjen sa strings.Cut
327 lines
10 KiB
Go
327 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"ntech/internal/auth"
|
|
ntechsqlite "ntech/internal/db/sqlite"
|
|
"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) {
|
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
|
if err == nil && !postoji {
|
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
greska := r.URL.Query().Get("greska")
|
|
|
|
// login_pozadina i stilovi se čitaju bez prijavljenog korisnika — koristimo background kontekst
|
|
loginPozadina := ""
|
|
loginOpacity := "50"
|
|
loginBlurPozadine := "0"
|
|
loginBlurKartice := "12"
|
|
loginZatamnjenjeKartice := "0"
|
|
if podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(context.Background(), h.DB); err == nil {
|
|
loginPozadina = podesavanja["login_pozadina"]
|
|
if v := podesavanja["login_pozadina_opacity"]; v != "" {
|
|
loginOpacity = v
|
|
}
|
|
if v := podesavanja["login_pozadina_blur_pozadine"]; v != "" {
|
|
loginBlurPozadine = v
|
|
}
|
|
if v := podesavanja["login_pozadina_blur_kartice"]; v != "" {
|
|
loginBlurKartice = v
|
|
}
|
|
if v := podesavanja["login_pozadina_zatamnjenje_kartice"]; v != "" {
|
|
loginZatamnjenjeKartice = v
|
|
}
|
|
}
|
|
|
|
h.renderujStandalone(w, "prijava", map[string]any{
|
|
"Greska": greska,
|
|
"CsrfToken": middleware.CsrfToken(r.Context()),
|
|
"LoginPozadina": loginPozadina,
|
|
"LoginPozadinaOpacity": loginOpacity,
|
|
"LoginPozadinaBlurPozadine": loginBlurPozadine,
|
|
"LoginPozadinaBlurKartice": loginBlurKartice,
|
|
"LoginPozadinaZatamnjenjeKartice": loginZatamnjenjeKartice,
|
|
"Verzija": h.Verzija,
|
|
"JelDemo": h.JelDemo,
|
|
})
|
|
}
|
|
|
|
// Prijava obrađuje POST formu za prijavu
|
|
func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
ip := izvuciIP(r)
|
|
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.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "ip_zaklucano", false)
|
|
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 {
|
|
// izjednačavamo vreme odgovora sa slučajem postojećeg korisnika (anti-enumeracija)
|
|
auth.IzjednaciVremeProvere(lozinka)
|
|
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
|
|
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false)
|
|
auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user")
|
|
http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !korisnik.Aktivan {
|
|
_ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false)
|
|
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "nalog_neaktivan", false)
|
|
auth.LogNeuspehPrijave(ip, korisnickoIme, "nalog_neaktivan")
|
|
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)
|
|
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešna_lozinka", 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)
|
|
_ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "", true)
|
|
auth.LogUspehPrijave(ip, korisnickoIme)
|
|
|
|
token := auth.GenerisiToken()
|
|
|
|
if korisnik.TotpTajna != "" {
|
|
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
|
|
}
|
|
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjePredSeije)))
|
|
http.Redirect(w, r, "/prijava/totp", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
http.SetCookie(w, napraviKolacic(token, time.Now().Add(trajanjeSeije)))
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
}
|
|
|
|
// PrikazTotp renderuje formu za unos TOTP koda
|
|
func (h *Handler) PrikazTotp(w http.ResponseWriter, r *http.Request) {
|
|
kolacic, err := r.Cookie(imeKolacica)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
|
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
greska := r.URL.Query().Get("greska")
|
|
h.renderujStandalone(w, "totp_provera", map[string]any{
|
|
"Greska": greska,
|
|
"CsrfToken": middleware.CsrfToken(r.Context()),
|
|
})
|
|
}
|
|
|
|
// VerifikujTotp obrađuje POST formu za TOTP verifikaciju
|
|
func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
|
|
kolacic, err := r.Cookie(imeKolacica)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
sesija, err := h.SesijeRepo.DohvatiPoTokenu(r.Context(), kolacic.Value)
|
|
if err != nil || sesija.TotpPotvrdjeno || time.Now().After(sesija.DatumIsteka) {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), sesija.KorisnikID)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
kod := r.FormValue("kod")
|
|
validan := auth.VerifikujTotpKod(kod, korisnik.TotpTajna)
|
|
if !validan {
|
|
// fallback: pokušaj kao rezervni (jednokratni) kod
|
|
if ok, err := h.RezervniKodoviRepo.Iskoristi(r.Context(), korisnik.ID, auth.NormalizujRezervniKod(kod)); err == nil && ok {
|
|
validan = true
|
|
slog.Info("prijava rezervnim kodom", "korisnik_id", korisnik.ID)
|
|
}
|
|
}
|
|
if !validan {
|
|
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
novoIstice := time.Now().Add(trajanjeSeije)
|
|
if err := h.SesijeRepo.PotvrdiTotp(r.Context(), kolacic.Value, novoIstice); err != nil {
|
|
http.Redirect(w, r, "/prijava?greska=2", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, napraviKolacic(kolacic.Value, novoIstice))
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
}
|
|
|
|
// PrikazSetupa renderuje setup wizard za prvog korisnika
|
|
func (h *Handler) PrikazSetupa(w http.ResponseWriter, r *http.Request) {
|
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
|
if err == nil && postoji {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
greska := r.URL.Query().Get("greska")
|
|
h.renderujStandalone(w, "setup", map[string]any{
|
|
"Greska": greska,
|
|
"CsrfToken": middleware.CsrfToken(r.Context()),
|
|
})
|
|
}
|
|
|
|
// SacuvajSetup kreira prvog superadmin korisnika
|
|
func (h *Handler) SacuvajSetup(w http.ResponseWriter, r *http.Request) {
|
|
postoji, err := h.KorisniciRepo.PostojiIjedan(r.Context())
|
|
if err == nil && postoji {
|
|
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
ime := r.FormValue("korisnicko_ime")
|
|
lozinka := r.FormValue("lozinka")
|
|
potvrda := r.FormValue("lozinka_potvrda")
|
|
|
|
if len(ime) < 3 || len(lozinka) < 8 || lozinka != potvrda {
|
|
http.Redirect(w, r, "/setup?greska=1", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
hash, err := auth.HashujLozinku(lozinka)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if _, err := h.KorisniciRepo.Kreiraj(r.Context(), ime, hash, "superadmin"); err != nil {
|
|
http.Redirect(w, r, "/setup?greska=2", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/prijava?sacuvano=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// Odjava briše sesiju i kolačić
|
|
func (h *Handler) Odjava(w http.ResponseWriter, r *http.Request) {
|
|
if kolacic, err := r.Cookie(imeKolacica); err == nil {
|
|
_ = h.SesijeRepo.Obrisi(r.Context(), kolacic.Value)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: imeKolacica,
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
|
HttpOnly: true,
|
|
})
|
|
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 čita pravi IP klijenta — najpre X-Real-IP (koji nginx postavlja),
|
|
// zatim poslednji X-Forwarded-For (dodat od strane proxy-a), pa RemoteAddr
|
|
func izvuciIP(r *http.Request) string {
|
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
// uzimamo poslednju vrednost — nju dodaje naš proxy, ne klijent
|
|
parts := strings.Split(fwd, ",")
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func napraviKolacic(token string, istice time.Time) *http.Cookie {
|
|
return &http.Cookie{
|
|
Name: imeKolacica,
|
|
Value: token,
|
|
Path: "/",
|
|
Expires: istice,
|
|
HttpOnly: true,
|
|
Secure: os.Getenv("NTECH_ENV") == "production" || os.Getenv("NTECH_ENV") == "demo",
|
|
SameSite: http.SameSiteStrictMode,
|
|
}
|
|
}
|