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