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
+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
}
// 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 {
+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 {
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()