feat(2fa): rezervni (jednokratni) kodovi za 2FA

Alternativa TOTP-u kada uređaj nije dostupan. Po CLAUDE.md specifikaciji:
10 kodova pri aktivaciji, čuvani kao bcrypt heš.

Backend:
- migracija 039 (tabela rezervni_kodovi, FK CASCADE)
- auth.GenerisiRezervneKodove (Crockford base32, XXXX-XXXX) + NormalizujRezervniKod
- RezervniKodoviRepository (Zameni/Iskoristi/BrojPreostalih/Obrisi) + SQLite impl
- žičenje u Handler (+ reinicijalizuj)

Prijava:
- VerifikujTotp prvo proba TOTP, pa rezervni kod (isto polje); kod je jednokratni
- totp_provera.html: input opušten (slova/crtica), napomena o rezervnom kodu

Profil:
- aktivacija generiše i prikazuje kodove JEDNOM; dugme Regeneriši; brojač preostalo X/10
- deaktivacija briše kodove

Testovi: auth (generisanje/format/normalizacija), repo (jednokratnost/regeneracija),
prijava rezervnim kodom end-to-end. Ukupno 36 test funkcija.
This commit is contained in:
2026-06-12 23:44:09 +02:00
parent ba78f243c0
commit b112d46e4e
15 changed files with 489 additions and 48 deletions
+72 -6
View File
@@ -26,11 +26,13 @@ type podaciLoginIstorija struct {
type podaciAdminProfil struct {
model.PodaciStranice
// Greska se koristi samo za inline prikaz greške pri TOTP aktivaciji (bez redirecta)
Greska string
TotpURI string
TotpTajna string
TotpQR template.URL
Greska string
TotpURI string
TotpTajna string
TotpQR template.URL
TotpAktivan bool
RezervniKodovi []string // jednokratni prikaz novih kodova (posle aktivacije/regeneracije)
BrojRezervnih int // koliko neiskorišćenih rezervnih kodova je preostalo
LokalnaTema string
KoristiLokalnuTemu bool
}
@@ -297,14 +299,59 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "profil"
ps.NaslovStranice = "Moj profil"
var brojRezervnih int
if svezi.TotpTajna != "" {
brojRezervnih, _ = h.RezervniKodoviRepo.BrojPreostalih(r.Context(), svezi.ID)
}
h.renderujTemplate(w, "admin_profil", podaciAdminProfil{
PodaciStranice: ps,
TotpAktivan: svezi.TotpTajna != "",
BrojRezervnih: brojRezervnih,
LokalnaTema: svezi.LokalnaTema,
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu,
})
}
// generisiIPrikaziKodove generiše nove rezervne kodove, čuva njihove bcrypt hešove
// (poništavajući stare) i renderuje profil sa kodovima prikazanim JEDNOM. Čist
// tekst kodova se nigde ne čuva — korisnik mora da ih sačuva sada.
func (h *Handler) generisiIPrikaziKodove(w http.ResponseWriter, r *http.Request, k *model.Korisnik, poruka string) {
kodovi, err := auth.GenerisiRezervneKodove(auth.BrojRezervnihKodova)
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri generisanju rezervnih kodova.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
hashevi := make([]string, 0, len(kodovi))
for _, kod := range kodovi {
hash, err := auth.HashujLozinku(kod)
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju rezervnih kodova.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
hashevi = append(hashevi, hash)
}
if err := h.RezervniKodoviRepo.Zameni(r.Context(), k.ID, hashevi); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju rezervnih kodova.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
ps := h.popuniPodaciStranice(r, podesavanja)
ps.Stranica = "profil"
ps.NaslovStranice = "Moj profil"
ps.Flash = &model.FlashPoruka{Tip: "uspeh", Poruka: poruka}
h.renderujTemplate(w, "admin_profil", podaciAdminProfil{
PodaciStranice: ps,
TotpAktivan: true,
RezervniKodovi: kodovi,
BrojRezervnih: len(kodovi),
})
}
// AdminPromeniLozinku menja lozinku prijavljenog korisnika
func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
@@ -426,8 +473,25 @@ func (h *Handler) AdminTotpAktivacija(w http.ResponseWriter, r *http.Request) {
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je uspešno uključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
// uključenjem 2FA generišemo i rezervne kodove i prikazujemo ih jednom
h.generisiIPrikaziKodove(w, r, k,
"Dvostepena verifikacija je uključena. Sačuvajte rezervne kodove — prikazuju se samo sada.")
}
// AdminTotpRegenerisiKodove generiše nove rezervne kodove i poništava stare
func (h *Handler) AdminTotpRegenerisiKodove(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
svezi, err := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID)
if err != nil || svezi.TotpTajna == "" {
middleware.SetFlash(w, r, h.DB, "greska", "Dvostepena verifikacija nije uključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
h.generisiIPrikaziKodove(w, r, k, "Generisani su novi rezervni kodovi. Stari više ne važe.")
}
// AdminTotpDeaktivacija uklanja TOTP tajnu
@@ -443,6 +507,8 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return
}
// isključenjem 2FA brišemo i rezervne kodove
_ = h.RezervniKodoviRepo.Obrisi(r.Context(), k.ID)
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
+39 -36
View File
@@ -15,29 +15,30 @@ import (
// Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct {
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
DB *sql.DB
PutanjaBaze string
Artikli db.ArtikalRepository
KategorijeRepo db.KategorijaRepository
DobavljaciRepo db.DobavljacRepository
NabavkeRepo db.NabavkaRepository
KlijentiRepo db.KlijentRepository
ServisRepo db.ServisRepository
ServisniDeloviRepo db.ServisniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
Verzija string
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository
RezervniKodoviRepo db.RezervniKodoviRepository
PokusajiRepo db.PokusajiPrijaveRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository
DozvoleRepo db.DozvoleRepository
Verzija string
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
Templates map[string]*template.Template
TemplatesFS fs.FS
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
// mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova
@@ -73,24 +74,25 @@ func (h *Handler) SaBazom(fn func(*sql.DB)) {
// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
return &Handler{
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
DB: baza,
totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
}
}
@@ -112,6 +114,7 @@ func (h *Handler) reinicijalizujRepozitorijume(novaDB *sql.DB) {
h.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
h.RezervniKodoviRepo = sqlite.NoviRezervniKodoviRepo(novaDB)
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
+10 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
@@ -182,7 +183,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
}
kod := r.FormValue("kod")
if !auth.VerifikujTotpKod(kod, korisnik.TotpTajna) {
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
}
+43
View File
@@ -220,6 +220,49 @@ func TestTotpTok(t *testing.T) {
}
}
func TestPrijavaRezervnimKodom(t *testing.T) {
h := testHandler(t)
ctx := context.Background()
id := seedKorisnik(t, h, "pera", "tajna123", "radnik")
_ = h.KorisniciRepo.SacuvajTotpTajnu(ctx, id, "JBSWY3DPEHPK3PXP")
// generiši i sačuvaj rezervne kodove
kodovi, err := auth.GenerisiRezervneKodove(5)
if err != nil {
t.Fatalf("GenerisiRezervneKodove: %v", err)
}
var hashevi []string
for _, kod := range kodovi {
hash, _ := auth.HashujLozinku(kod)
hashevi = append(hashevi, hash)
}
if err := h.RezervniKodoviRepo.Zameni(ctx, id, hashevi); err != nil {
t.Fatalf("Zameni: %v", err)
}
// prijava lozinkom → pred-sesija
w := httptest.NewRecorder()
h.Prijava(w, postPrijava("pera", "tajna123"))
token := sesijskiKolacic(w.Result())
// umesto TOTP-a unesi REZERVNI kod
form := url.Values{}
form.Set("kod", kodovi[0])
r := httptest.NewRequest("POST", "/prijava/totp", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(&http.Cookie{Name: imeKolacica, Value: token})
w2 := httptest.NewRecorder()
h.VerifikujTotp(w2, r)
if loc := w2.Result().Header.Get("Location"); loc != "/dashboard" {
t.Fatalf("rezervnim kodom Location = %q, očekivano /dashboard", loc)
}
// kod je potrošen (jednokratni) — preostalo 4
if n, _ := h.RezervniKodoviRepo.BrojPreostalih(ctx, id); n != 4 {
t.Fatalf("posle upotrebe preostalo %d kodova, očekivano 4", n)
}
}
func TestTotpPogresanKod(t *testing.T) {
h := testHandler(t)
ctx := context.Background()