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)
}
}
}