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