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
+1 -1
View File
@@ -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 interface — sidebar navigation, theme system (dark/light), dashboard with statistics
- User login — server-side sessions, account locking - 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) - 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 - Brute-force protection — IP locking after 5 failed attempts within 15 minutes
- CSRF protection — double-submit cookie pattern, automatic token injection into all forms - CSRF protection — double-submit cookie pattern, automatic token injection into all forms
- Security HTTP headers (CSP, X-Frame-Options, Referrer-Policy, nosniff...) - 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) - Fiscalization and VAT calculation (specification in Project.md)
- PostgreSQL support (for multi-user environments) - PostgreSQL support (for multi-user environments)
- WebAuthn / Passkey login (database schema is already prepared) - WebAuthn / Passkey login (database schema is already prepared)
- Backup (one-time) codes for 2FA
- Notifications (email / WhatsApp) — deferred to a later phase - Notifications (email / WhatsApp) — deferred to a later phase
- Barcode scanning via camera — deferred to a later phase - Barcode scanning via camera — deferred to a later phase
+1 -1
View File
@@ -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 - Korisnički interfejs — sidebar navigacija, sistem tema (tamna/svetla), dashboard sa statistikama
- Prijava korisnika — sesije na serveru, zaključavanje naloga - 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) - 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 - 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 - CSRF zaštita — double-submit cookie pattern, automatska injekcija tokena u sve forme
- Bezbednosni HTTP headeri (CSP, X-Frame-Options, Referrer-Policy, nosniff...) - 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) - Fiskalizacija i PDV obračun (specifikacija u Project.md)
- Podrška za PostgreSQL (za višekorisničko okruženje) - Podrška za PostgreSQL (za višekorisničko okruženje)
- WebAuthn / Passkey prijava (šema baze je pripremljena) - WebAuthn / Passkey prijava (šema baze je pripremljena)
- Rezervni (jednokratni) kodovi za 2FA
- Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu - Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu
- Skeniranje barkodova putem kamere — odloženo za kasniju fazu - Skeniranje barkodova putem kamere — odloženo za kasniju fazu
+1
View File
@@ -316,6 +316,7 @@ func main() {
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni) r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija) r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija)
r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija) 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.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
r.Get("/profil/tema", h.ProfilTema) r.Get("/profil/tema", h.ProfilTema)
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
+61
View File
@@ -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
}
+37
View File
@@ -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)
}
}
}
+9
View File
@@ -143,6 +143,15 @@ type DozvoleRepository interface {
Reset(ctx context.Context) error 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. // IzvestajRepository definiše read-only upite za dashboard i stranicu izveštaja.
// Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler. // Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler.
type IzvestajRepository interface { type IzvestajRepository interface {
+98
View File
@@ -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
}
@@ -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)
}
}
+72 -6
View File
@@ -26,11 +26,13 @@ type podaciLoginIstorija struct {
type podaciAdminProfil struct { type podaciAdminProfil struct {
model.PodaciStranice model.PodaciStranice
// Greska se koristi samo za inline prikaz greške pri TOTP aktivaciji (bez redirecta) // Greska se koristi samo za inline prikaz greške pri TOTP aktivaciji (bez redirecta)
Greska string Greska string
TotpURI string TotpURI string
TotpTajna string TotpTajna string
TotpQR template.URL TotpQR template.URL
TotpAktivan bool TotpAktivan bool
RezervniKodovi []string // jednokratni prikaz novih kodova (posle aktivacije/regeneracije)
BrojRezervnih int // koliko neiskorišćenih rezervnih kodova je preostalo
LokalnaTema string LokalnaTema string
KoristiLokalnuTemu bool KoristiLokalnuTemu bool
} }
@@ -297,14 +299,59 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
ps.Stranica = "profil" ps.Stranica = "profil"
ps.NaslovStranice = "Moj 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{ h.renderujTemplate(w, "admin_profil", podaciAdminProfil{
PodaciStranice: ps, PodaciStranice: ps,
TotpAktivan: svezi.TotpTajna != "", TotpAktivan: svezi.TotpTajna != "",
BrojRezervnih: brojRezervnih,
LokalnaTema: svezi.LokalnaTema, LokalnaTema: svezi.LokalnaTema,
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu, 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 // AdminPromeniLozinku menja lozinku prijavljenog korisnika
func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) { 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 return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je uspešno uključena.") // uključenjem 2FA generišemo i rezervne kodove i prikazujemo ih jednom
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) 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 // 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) http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
return 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.") middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) 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 // Handler drži zavisnosti koje su potrebne svim handlerima
type Handler struct { type Handler struct {
DB *sql.DB DB *sql.DB
PutanjaBaze string PutanjaBaze string
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
ServisniDeloviRepo db.ServisniDeloviRepository ServisniDeloviRepo db.ServisniDeloviRepository
MagacinskePromeneRepo db.MagacinskePromeneRepository MagacinskePromeneRepo db.MagacinskePromeneRepository
ProdajaRepo db.ProdajaRepository ProdajaRepo db.ProdajaRepository
KorisniciRepo db.KorisniciRepository KorisniciRepo db.KorisniciRepository
SesijeRepo db.SesijeRepository SesijeRepo db.SesijeRepository
PodsetnikRepo db.PodsetnikRepository PodsetnikRepo db.PodsetnikRepository
IzvestajRepo db.IzvestajRepository IzvestajRepo db.IzvestajRepository
PokusajiRepo db.PokusajiPrijaveRepository RezervniKodoviRepo db.RezervniKodoviRepository
LoginIstorijsaRepo db.LoginIstorijsaRepository PokusajiRepo db.PokusajiPrijaveRepository
DozvoleRepo db.DozvoleRepository LoginIstorijsaRepo db.LoginIstorijsaRepository
Verzija string DozvoleRepo db.DozvoleRepository
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju) Verzija string
Templates map[string]*template.Template AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
TemplatesFS fs.FS Templates map[string]*template.Template
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo) 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). // mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova // 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. // totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
func Novi(baza *sql.DB, totpKljuc []byte) *Handler { func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
return &Handler{ return &Handler{
DB: baza, DB: baza,
totpKljuc: totpKljuc, totpKljuc: totpKljuc,
Artikli: sqlite.NoviArtikalRepo(baza), Artikli: sqlite.NoviArtikalRepo(baza),
KategorijeRepo: sqlite.NovaKategorijaRepo(baza), KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza), DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
NabavkeRepo: sqlite.NoviNabavkaRepo(baza), NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
KlijentiRepo: sqlite.NoviKlijentRepo(baza), KlijentiRepo: sqlite.NoviKlijentRepo(baza),
ServisRepo: sqlite.NoviServisRepo(baza), ServisRepo: sqlite.NoviServisRepo(baza),
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza), ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza), MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
ProdajaRepo: sqlite.NoviProdajaRepo(baza), ProdajaRepo: sqlite.NoviProdajaRepo(baza),
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc), KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
SesijeRepo: sqlite.NoviSesijeRepo(baza), SesijeRepo: sqlite.NoviSesijeRepo(baza),
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza), PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
IzvestajRepo: sqlite.NoviIzvestajRepo(baza), IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()), 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.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB) h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB) h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
h.RezervniKodoviRepo = sqlite.NoviRezervniKodoviRepo(novaDB)
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB) h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB) h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije()) h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
+10 -1
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -182,7 +183,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
} }
kod := r.FormValue("kod") 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) http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
return 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) { func TestTotpPogresanKod(t *testing.T) {
h := testHandler(t) h := testHandler(t)
ctx := context.Background() ctx := context.Background()
+12
View File
@@ -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);
+17
View File
@@ -77,6 +77,23 @@
</form> </form>
</div> </div>
<!-- Rezervni (jednokratni) kodovi -->
<div style="margin-top: 18px; padding-top: 16px; border-top: 0.5px solid var(--ivica)">
{{ if .RezervniKodovi }}
<div class="poruka-uspeh" style="margin-bottom: 12px">Sačuvajte ove rezervne kodove na sigurno mesto — prikazuju se samo sada. Svaki se može upotrebiti jednom, umesto koda iz aplikacije.</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 14px; max-width: 360px">
{{ range .RezervniKodovi }}
<code style="font-size: 14px; background: var(--pozadina); padding: 8px 10px; border-radius: 6px; text-align: center; letter-spacing: 1px; color: var(--tekst-glavni)">{{ . }}</code>
{{ end }}
</div>
{{ else }}
<div style="font-size: 13px; color: var(--tekst-sporedni); margin-bottom: 10px">Rezervni kodovi: preostalo <strong>{{ .BrojRezervnih }}</strong>. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.</div>
{{ end }}
<form method="POST" action="/admin/profil/totp/kodovi" onsubmit="return confirm('Generisati nove rezervne kodove? Stari kodovi više neće važiti.');">
<button type="submit" style="padding: 8px 16px; background: transparent; color: var(--sb-akcent); border: 1px solid var(--sb-akcent); border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer">Regeneriši rezervne kodove</button>
</form>
</div>
{{ else }} {{ else }}
<!-- 2FA nije uključena --> <!-- 2FA nije uključena -->
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px"> <div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px">
+2 -3
View File
@@ -30,7 +30,7 @@
</div> </div>
<h1>Dvostepena verifikacija</h1> <h1>Dvostepena verifikacija</h1>
<p class="opis">Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju.</p> <p class="opis">Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju, ili jedan od rezervnih kodova (format XXXX-XXXX) ako nemate pristup aplikaciji.</p>
{{if eq .Greska "1"}} {{if eq .Greska "1"}}
<div class="greska">Neispravan kod. Pokušajte ponovo.</div> <div class="greska">Neispravan kod. Pokušajte ponovo.</div>
@@ -41,8 +41,7 @@
<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"
inputmode="numeric" pattern="[0-9]{6}" maxlength="12" autocomplete="one-time-code" autofocus required>
maxlength="6" autocomplete="one-time-code" autofocus required>
</div> </div>
<button type="submit" class="dugme">Potvrdi</button> <button type="submit" class="dugme">Potvrdi</button>
</form> </form>