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