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:
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user