From b112d46e4e92cb58afc3819594d5ce7a80c9dd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Fri, 12 Jun 2026 23:44:09 +0200 Subject: [PATCH] feat(2fa): rezervni (jednokratni) kodovi za 2FA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Readme.md | 2 +- Readme_sr.md | 2 +- cmd/ntech/main.go | 1 + internal/auth/rezervni.go | 61 ++++++++++++++ internal/auth/rezervni_test.go | 37 ++++++++ internal/db/repository.go | 9 ++ internal/db/sqlite/rezervni_kodovi.go | 98 ++++++++++++++++++++++ internal/db/sqlite/rezervni_kodovi_test.go | 86 +++++++++++++++++++ internal/handler/admin.go | 78 +++++++++++++++-- internal/handler/handler.go | 75 +++++++++-------- internal/handler/prijava.go | 11 ++- internal/handler/prijava_test.go | 43 ++++++++++ migrations/039_rezervni_kodovi.sql | 12 +++ web/templates/stranice/admin_profil.html | 17 ++++ web/templates/stranice/totp_provera.html | 5 +- 15 files changed, 489 insertions(+), 48 deletions(-) create mode 100644 internal/auth/rezervni.go create mode 100644 internal/auth/rezervni_test.go create mode 100644 internal/db/sqlite/rezervni_kodovi.go create mode 100644 internal/db/sqlite/rezervni_kodovi_test.go create mode 100644 migrations/039_rezervni_kodovi.sql diff --git a/Readme.md b/Readme.md index 8718d7e..f3e27be 100644 --- a/Readme.md +++ b/Readme.md @@ -30,6 +30,7 @@ The goal is simple: everything the repair shop needs to track is located in one - User interface — sidebar navigation, theme system (dark/light), dashboard with statistics - User login — server-side sessions, account locking - Two-factor authentication (TOTP) — activation with a QR code; secret encrypted at rest (AES-256-GCM, key kept outside the database) +- Backup (one-time) codes for 2FA — generated on activation, stored as bcrypt hashes; a fallback to TOTP at login - Brute-force protection — IP locking after 5 failed attempts within 15 minutes - CSRF protection — double-submit cookie pattern, automatic token injection into all forms - Security HTTP headers (CSP, X-Frame-Options, Referrer-Policy, nosniff...) @@ -57,7 +58,6 @@ The goal is simple: everything the repair shop needs to track is located in one - Fiscalization and VAT calculation (specification in Project.md) - PostgreSQL support (for multi-user environments) - WebAuthn / Passkey login (database schema is already prepared) -- Backup (one-time) codes for 2FA - Notifications (email / WhatsApp) — deferred to a later phase - Barcode scanning via camera — deferred to a later phase diff --git a/Readme_sr.md b/Readme_sr.md index 7fbc13f..8842782 100644 --- a/Readme_sr.md +++ b/Readme_sr.md @@ -30,6 +30,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b - Korisnički interfejs — sidebar navigacija, sistem tema (tamna/svetla), dashboard sa statistikama - Prijava korisnika — sesije na serveru, zaključavanje naloga - Dvofaktorska autentifikacija (TOTP) — aktivacija sa QR kodom; tajna šifrovana u bazi (AES-256-GCM, ključ van baze) +- Rezervni (jednokratni) kodovi za 2FA — generišu se pri aktivaciji, čuvaju kao bcrypt heš; alternativa TOTP-u pri prijavi - Bruteforce zaštita — IP zaključavanje nakon 5 neuspelih pokušaja u 15 minuta - CSRF zaštita — double-submit cookie pattern, automatska injekcija tokena u sve forme - Bezbednosni HTTP headeri (CSP, X-Frame-Options, Referrer-Policy, nosniff...) @@ -57,7 +58,6 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b - Fiskalizacija i PDV obračun (specifikacija u Project.md) - Podrška za PostgreSQL (za višekorisničko okruženje) - WebAuthn / Passkey prijava (šema baze je pripremljena) -- Rezervni (jednokratni) kodovi za 2FA - Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu - Skeniranje barkodova putem kamere — odloženo za kasniju fazu diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 814320b..bcbdf77 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -316,6 +316,7 @@ func main() { r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija) r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija) + r.Post("/admin/profil/totp/kodovi", h.AdminTotpRegenerisiKodove) r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu) r.Get("/profil/tema", h.ProfilTema) r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) diff --git a/internal/auth/rezervni.go b/internal/auth/rezervni.go new file mode 100644 index 0000000..6ecb89a --- /dev/null +++ b/internal/auth/rezervni.go @@ -0,0 +1,61 @@ +package auth + +import ( + "crypto/rand" + "fmt" + "strings" +) + +// alfabetKodova je Crockford base32 (32 znaka — deli 256 bez ostatka, pa nema +// modulo pristrasnosti) — izostavlja dvosmislene znakove I, L, O, U. +const alfabetKodova = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// BrojRezervnihKodova je podrazumevani broj kodova koji se generiše pri aktivaciji. +const BrojRezervnihKodova = 10 + +// GenerisiRezervneKodove vraća n nasumičnih jednokratnih kodova formata "XXXX-XXXX". +// Vraća čist tekst — pozivalac ih prikazuje korisniku jednom i čuva samo bcrypt heš. +func GenerisiRezervneKodove(n int) ([]string, error) { + kodovi := make([]string, 0, n) + for i := 0; i < n; i++ { + kod, err := nasumicanKod() + if err != nil { + return nil, fmt.Errorf("ntech: auth.GenerisiRezervneKodove: %w", err) + } + kodovi = append(kodovi, kod) + } + return kodovi, nil +} + +// nasumicanKod gradi jedan kod "XXXX-XXXX" iz alfabetKodova +func nasumicanKod() (string, error) { + bajtovi := make([]byte, 8) + if _, err := rand.Read(bajtovi); err != nil { + return "", err + } + var sb strings.Builder + for i, b := range bajtovi { + if i == 4 { + sb.WriteByte('-') + } + sb.WriteByte(alfabetKodova[int(b)%len(alfabetKodova)]) + } + return sb.String(), nil +} + +// NormalizujRezervniKod dovodi korisnikov unos u kanonski oblik "XXXX-XXXX": +// uklanja razmake/crtice, prebacuje u velika slova i ubacuje crticu na sredini. +// Tako se isti kod prepoznaje bez obzira na to kako ga korisnik otkuca. +func NormalizujRezervniKod(unos string) string { + var sb strings.Builder + for _, r := range strings.ToUpper(unos) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + sb.WriteRune(r) + } + } + s := sb.String() + if len(s) == 8 { + return s[:4] + "-" + s[4:] + } + return s +} diff --git a/internal/auth/rezervni_test.go b/internal/auth/rezervni_test.go new file mode 100644 index 0000000..0c9ad44 --- /dev/null +++ b/internal/auth/rezervni_test.go @@ -0,0 +1,37 @@ +package auth + +import "testing" + +func TestGenerisiRezervneKodove(t *testing.T) { + kodovi, err := GenerisiRezervneKodove(10) + if err != nil { + t.Fatalf("GenerisiRezervneKodove: %v", err) + } + if len(kodovi) != 10 { + t.Fatalf("dobijeno %d kodova, očekivano 10", len(kodovi)) + } + vidjeni := map[string]bool{} + for _, k := range kodovi { + if len(k) != 9 || k[4] != '-' { + t.Fatalf("kod nije u formatu XXXX-XXXX: %q", k) + } + if vidjeni[k] { + t.Fatalf("ponovljen kod: %q", k) + } + vidjeni[k] = true + } +} + +func TestNormalizujRezervniKod(t *testing.T) { + slucajevi := map[string]string{ + "ABCD-2345": "ABCD-2345", + "abcd2345": "ABCD-2345", + "abcd 2345": "ABCD-2345", + " a b c d 2 3 4 5 ": "ABCD-2345", + } + for ulaz, ocek := range slucajevi { + if got := NormalizujRezervniKod(ulaz); got != ocek { + t.Errorf("NormalizujRezervniKod(%q) = %q, očekivano %q", ulaz, got, ocek) + } + } +} diff --git a/internal/db/repository.go b/internal/db/repository.go index a766c94..ab4e689 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -143,6 +143,15 @@ type DozvoleRepository interface { Reset(ctx context.Context) error } +// RezervniKodoviRepository definiše operacije nad rezervnim (jednokratnim) 2FA kodovima. +// Kodovi se čuvaju kao bcrypt heš; Iskoristi prima čist kod i poredi ga sa hešovima. +type RezervniKodoviRepository interface { + Zameni(ctx context.Context, korisnikID int64, hashevi []string) error + Iskoristi(ctx context.Context, korisnikID int64, kod string) (bool, error) + BrojPreostalih(ctx context.Context, korisnikID int64) (int, error) + Obrisi(ctx context.Context, korisnikID int64) error +} + // IzvestajRepository definiše read-only upite za dashboard i stranicu izveštaja. // Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler. type IzvestajRepository interface { diff --git a/internal/db/sqlite/rezervni_kodovi.go b/internal/db/sqlite/rezervni_kodovi.go new file mode 100644 index 0000000..0eadb87 --- /dev/null +++ b/internal/db/sqlite/rezervni_kodovi.go @@ -0,0 +1,98 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/auth" +) + +type sqliteRezervniKodoviRepo struct{ db *sql.DB } + +// NoviRezervniKodoviRepo kreira SQLite implementaciju RezervniKodoviRepository +func NoviRezervniKodoviRepo(db *sql.DB) *sqliteRezervniKodoviRepo { + return &sqliteRezervniKodoviRepo{db: db} +} + +// Zameni briše sve postojeće kodove korisnika i upisuje nove (regeneracija) u +// jednoj transakciji — stari kodovi se time poništavaju. +func (r *sqliteRezervniKodoviRepo) Zameni(ctx context.Context, korisnikID int64, hashevi []string) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("ntech: rezervni.Zameni: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM rezervni_kodovi WHERE korisnik_id = ?`, korisnikID); err != nil { + return fmt.Errorf("ntech: rezervni.Zameni: %w", err) + } + for _, h := range hashevi { + if _, err := tx.ExecContext(ctx, + `INSERT INTO rezervni_kodovi (korisnik_id, kod_hash) VALUES (?, ?)`, korisnikID, h); err != nil { + return fmt.Errorf("ntech: rezervni.Zameni: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("ntech: rezervni.Zameni: %w", err) + } + return nil +} + +// Iskoristi proverava dati (već normalizovan) kod protiv neiskorišćenih kodova +// korisnika. Ako se poklopi, označava ga iskorišćenim i vraća true; inače false. +func (r *sqliteRezervniKodoviRepo) Iskoristi(ctx context.Context, korisnikID int64, kod string) (bool, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, kod_hash FROM rezervni_kodovi WHERE korisnik_id = ? AND iskoriscen = 0`, korisnikID) + if err != nil { + return false, fmt.Errorf("ntech: rezervni.Iskoristi: %w", err) + } + type red struct { + id int64 + hash string + } + var redovi []red + for rows.Next() { + var rd red + if err := rows.Scan(&rd.id, &rd.hash); err != nil { + rows.Close() + return false, fmt.Errorf("ntech: rezervni.Iskoristi: %w", err) + } + redovi = append(redovi, rd) + } + rows.Close() + if err := rows.Err(); err != nil { + return false, fmt.Errorf("ntech: rezervni.Iskoristi: %w", err) + } + + // bcrypt poređenje protiv svakog neiskorišćenog koda (retka operacija — fallback) + for _, rd := range redovi { + if auth.ProveriLozinku(rd.hash, kod) { + if _, err := r.db.ExecContext(ctx, + `UPDATE rezervni_kodovi SET iskoriscen = 1, datum_koriscenja = ? WHERE id = ?`, + time.Now(), rd.id); err != nil { + return false, fmt.Errorf("ntech: rezervni.Iskoristi: %w", err) + } + return true, nil + } + } + return false, nil +} + +func (r *sqliteRezervniKodoviRepo) BrojPreostalih(ctx context.Context, korisnikID int64) (int, error) { + var n int + err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM rezervni_kodovi WHERE korisnik_id = ? AND iskoriscen = 0`, korisnikID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("ntech: rezervni.BrojPreostalih: %w", err) + } + return n, nil +} + +func (r *sqliteRezervniKodoviRepo) Obrisi(ctx context.Context, korisnikID int64) error { + if _, err := r.db.ExecContext(ctx, `DELETE FROM rezervni_kodovi WHERE korisnik_id = ?`, korisnikID); err != nil { + return fmt.Errorf("ntech: rezervni.Obrisi: %w", err) + } + return nil +} diff --git a/internal/db/sqlite/rezervni_kodovi_test.go b/internal/db/sqlite/rezervni_kodovi_test.go new file mode 100644 index 0000000..5129d0f --- /dev/null +++ b/internal/db/sqlite/rezervni_kodovi_test.go @@ -0,0 +1,86 @@ +package sqlite + +import ( + "context" + "testing" + + "ntech/internal/auth" +) + +// hashirajKodove generiše n kodova i vraća (čisti kodovi, bcrypt hešovi) +func hashirajKodove(t *testing.T, n int) ([]string, []string) { + t.Helper() + kodovi, err := auth.GenerisiRezervneKodove(n) + if err != nil { + t.Fatalf("GenerisiRezervneKodove: %v", err) + } + var hashevi []string + for _, kod := range kodovi { + h, err := auth.HashujLozinku(kod) + if err != nil { + t.Fatalf("hash: %v", err) + } + hashevi = append(hashevi, h) + } + return kodovi, hashevi +} + +func TestRezervniKodoviTok(t *testing.T) { + ctx := context.Background() + db := testDB(t) + korisnici := NoviKorisniciRepo(db, testKljuc(t)) + repo := NoviRezervniKodoviRepo(db) + + k, err := korisnici.Kreiraj(ctx, "pera", "hash", "radnik") + if err != nil { + t.Fatalf("Kreiraj: %v", err) + } + + kodovi, hashevi := hashirajKodove(t, 5) + if err := repo.Zameni(ctx, k.ID, hashevi); err != nil { + t.Fatalf("Zameni: %v", err) + } + if n, _ := repo.BrojPreostalih(ctx, k.ID); n != 5 { + t.Fatalf("BrojPreostalih = %d, očekivano 5", n) + } + + // upotreba prvog koda + if ok, err := repo.Iskoristi(ctx, k.ID, kodovi[0]); err != nil || !ok { + t.Fatalf("Iskoristi(prvi): ok=%v err=%v", ok, err) + } + // jednokratni — isti kod drugi put ne prolazi + if ok, _ := repo.Iskoristi(ctx, k.ID, kodovi[0]); ok { + t.Fatal("isti kod NE sme da prođe drugi put") + } + if n, _ := repo.BrojPreostalih(ctx, k.ID); n != 4 { + t.Fatalf("posle upotrebe BrojPreostalih = %d, očekivano 4", n) + } + + // nepostojeći kod ne prolazi + if ok, _ := repo.Iskoristi(ctx, k.ID, "XXXX-XXXX"); ok { + t.Fatal("nepostojeći kod ne sme da prođe") + } + + // Zameni poništava sve stare i postavlja nove + noviKodovi, noviHash := hashirajKodove(t, 3) + if err := repo.Zameni(ctx, k.ID, noviHash); err != nil { + t.Fatalf("Zameni (regeneracija): %v", err) + } + if n, _ := repo.BrojPreostalih(ctx, k.ID); n != 3 { + t.Fatalf("posle regeneracije BrojPreostalih = %d, očekivano 3", n) + } + if ok, _ := repo.Iskoristi(ctx, k.ID, kodovi[1]); ok { + t.Fatal("stari kod posle regeneracije ne sme da prođe") + } + if ok, err := repo.Iskoristi(ctx, k.ID, noviKodovi[0]); err != nil || !ok { + t.Fatalf("nov kod treba da prođe: ok=%v err=%v", ok, err) + } + + // Obrisi (deaktivacija 2FA) + if err := repo.Obrisi(ctx, k.ID); err != nil { + t.Fatalf("Obrisi: %v", err) + } + if n, _ := repo.BrojPreostalih(ctx, k.ID); n != 0 { + t.Fatalf("posle Obrisi BrojPreostalih = %d, očekivano 0", n) + } +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 61e4ad2..26e7fe1 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -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) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5604193..fa7dd79 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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()) diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index 60681ee..1a4b771 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -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 } diff --git a/internal/handler/prijava_test.go b/internal/handler/prijava_test.go index 05bbebf..7d6fd16 100644 --- a/internal/handler/prijava_test.go +++ b/internal/handler/prijava_test.go @@ -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() diff --git a/migrations/039_rezervni_kodovi.sql b/migrations/039_rezervni_kodovi.sql new file mode 100644 index 0000000..033f5be --- /dev/null +++ b/migrations/039_rezervni_kodovi.sql @@ -0,0 +1,12 @@ +-- Rezervni (jednokratni) kodovi za 2FA — alternativa TOTP-u kada uređaj nije dostupan. +-- Kod se čuva kao bcrypt heš; jednom iskorišćen kod se označava (iskoriscen=1). +CREATE TABLE IF NOT EXISTS rezervni_kodovi ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + korisnik_id INTEGER NOT NULL REFERENCES korisnici(id) ON DELETE CASCADE, + kod_hash TEXT NOT NULL, + iskoriscen INTEGER NOT NULL DEFAULT 0, + datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + datum_koriscenja DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_rezervni_kodovi_korisnik ON rezervni_kodovi(korisnik_id); diff --git a/web/templates/stranice/admin_profil.html b/web/templates/stranice/admin_profil.html index 83ebcaa..400f0c1 100644 --- a/web/templates/stranice/admin_profil.html +++ b/web/templates/stranice/admin_profil.html @@ -77,6 +77,23 @@ + +
+ {{ if .RezervniKodovi }} +
Sačuvajte ove rezervne kodove na sigurno mesto — prikazuju se samo sada. Svaki se može upotrebiti jednom, umesto koda iz aplikacije.
+
+ {{ range .RezervniKodovi }} + {{ . }} + {{ end }} +
+ {{ else }} +
Rezervni kodovi: preostalo {{ .BrojRezervnih }}. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.
+ {{ end }} +
+ +
+
+ {{ else }}
diff --git a/web/templates/stranice/totp_provera.html b/web/templates/stranice/totp_provera.html index d5a5a42..f4e2206 100644 --- a/web/templates/stranice/totp_provera.html +++ b/web/templates/stranice/totp_provera.html @@ -30,7 +30,7 @@

Dvostepena verifikacija

-

Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju.

+

Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju, ili jedan od rezervnih kodova (format XXXX-XXXX) ako nemate pristup aplikaciji.

{{if eq .Greska "1"}}
Neispravan kod. Pokušajte ponovo.
@@ -41,8 +41,7 @@
+ maxlength="12" autocomplete="one-time-code" autofocus required>