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:
@@ -30,6 +30,7 @@ The goal is simple: everything the repair shop needs to track is located in one
|
|||||||
- User interface — sidebar navigation, theme system (dark/light), dashboard with statistics
|
- User interface — sidebar navigation, theme system (dark/light), dashboard with statistics
|
||||||
- User login — server-side sessions, account locking
|
- User login — server-side sessions, account locking
|
||||||
- Two-factor authentication (TOTP) — activation with a QR code; secret encrypted at rest (AES-256-GCM, key kept outside the database)
|
- Two-factor authentication (TOTP) — activation with a QR code; secret encrypted at rest (AES-256-GCM, key kept outside the database)
|
||||||
|
- Backup (one-time) codes for 2FA — generated on activation, stored as bcrypt hashes; a fallback to TOTP at login
|
||||||
- Brute-force protection — IP locking after 5 failed attempts within 15 minutes
|
- Brute-force protection — IP locking after 5 failed attempts within 15 minutes
|
||||||
- CSRF protection — double-submit cookie pattern, automatic token injection into all forms
|
- CSRF protection — double-submit cookie pattern, automatic token injection into all forms
|
||||||
- Security HTTP headers (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
- Security HTTP headers (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
||||||
@@ -57,7 +58,6 @@ The goal is simple: everything the repair shop needs to track is located in one
|
|||||||
- Fiscalization and VAT calculation (specification in Project.md)
|
- Fiscalization and VAT calculation (specification in Project.md)
|
||||||
- PostgreSQL support (for multi-user environments)
|
- PostgreSQL support (for multi-user environments)
|
||||||
- WebAuthn / Passkey login (database schema is already prepared)
|
- WebAuthn / Passkey login (database schema is already prepared)
|
||||||
- Backup (one-time) codes for 2FA
|
|
||||||
- Notifications (email / WhatsApp) — deferred to a later phase
|
- Notifications (email / WhatsApp) — deferred to a later phase
|
||||||
- Barcode scanning via camera — deferred to a later phase
|
- Barcode scanning via camera — deferred to a later phase
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -30,6 +30,7 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
|||||||
- Korisnički interfejs — sidebar navigacija, sistem tema (tamna/svetla), dashboard sa statistikama
|
- Korisnički interfejs — sidebar navigacija, sistem tema (tamna/svetla), dashboard sa statistikama
|
||||||
- Prijava korisnika — sesije na serveru, zaključavanje naloga
|
- Prijava korisnika — sesije na serveru, zaključavanje naloga
|
||||||
- Dvofaktorska autentifikacija (TOTP) — aktivacija sa QR kodom; tajna šifrovana u bazi (AES-256-GCM, ključ van baze)
|
- Dvofaktorska autentifikacija (TOTP) — aktivacija sa QR kodom; tajna šifrovana u bazi (AES-256-GCM, ključ van baze)
|
||||||
|
- Rezervni (jednokratni) kodovi za 2FA — generišu se pri aktivaciji, čuvaju kao bcrypt heš; alternativa TOTP-u pri prijavi
|
||||||
- Bruteforce zaštita — IP zaključavanje nakon 5 neuspelih pokušaja u 15 minuta
|
- Bruteforce zaštita — IP zaključavanje nakon 5 neuspelih pokušaja u 15 minuta
|
||||||
- CSRF zaštita — double-submit cookie pattern, automatska injekcija tokena u sve forme
|
- CSRF zaštita — double-submit cookie pattern, automatska injekcija tokena u sve forme
|
||||||
- Bezbednosni HTTP headeri (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
- Bezbednosni HTTP headeri (CSP, X-Frame-Options, Referrer-Policy, nosniff...)
|
||||||
@@ -57,7 +58,6 @@ Cilj je jednostavan: sve što servis treba da prati nalazi se na jednom mestu, b
|
|||||||
- Fiskalizacija i PDV obračun (specifikacija u Project.md)
|
- Fiskalizacija i PDV obračun (specifikacija u Project.md)
|
||||||
- Podrška za PostgreSQL (za višekorisničko okruženje)
|
- Podrška za PostgreSQL (za višekorisničko okruženje)
|
||||||
- WebAuthn / Passkey prijava (šema baze je pripremljena)
|
- WebAuthn / Passkey prijava (šema baze je pripremljena)
|
||||||
- Rezervni (jednokratni) kodovi za 2FA
|
|
||||||
- Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu
|
- Obaveštenja (e-pošta / WhatsApp) — odloženo za kasniju fazu
|
||||||
- Skeniranje barkodova putem kamere — odloženo za kasniju fazu
|
- Skeniranje barkodova putem kamere — odloženo za kasniju fazu
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ func main() {
|
|||||||
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
r.Get("/admin/profil/totp/pokreni", h.AdminTotpPokreni)
|
||||||
r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija)
|
r.Post("/admin/profil/totp/aktiviraj", h.AdminTotpAktivacija)
|
||||||
r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija)
|
r.Post("/admin/profil/totp/deaktiviraj", h.AdminTotpDeaktivacija)
|
||||||
|
r.Post("/admin/profil/totp/kodovi", h.AdminTotpRegenerisiKodove)
|
||||||
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
|
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu)
|
||||||
r.Get("/profil/tema", h.ProfilTema)
|
r.Get("/profil/tema", h.ProfilTema)
|
||||||
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
|
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,15 @@ type DozvoleRepository interface {
|
|||||||
Reset(ctx context.Context) error
|
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.
|
// IzvestajRepository definiše read-only upite za dashboard i stranicu izveštaja.
|
||||||
// Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler.
|
// Vraća sirove podatke; prezentaciju (datumi, boje, rang) radi handler.
|
||||||
type IzvestajRepository interface {
|
type IzvestajRepository interface {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,13 @@ type podaciLoginIstorija struct {
|
|||||||
type podaciAdminProfil struct {
|
type podaciAdminProfil struct {
|
||||||
model.PodaciStranice
|
model.PodaciStranice
|
||||||
// Greska se koristi samo za inline prikaz greške pri TOTP aktivaciji (bez redirecta)
|
// Greska se koristi samo za inline prikaz greške pri TOTP aktivaciji (bez redirecta)
|
||||||
Greska string
|
Greska string
|
||||||
TotpURI string
|
TotpURI string
|
||||||
TotpTajna string
|
TotpTajna string
|
||||||
TotpQR template.URL
|
TotpQR template.URL
|
||||||
TotpAktivan bool
|
TotpAktivan bool
|
||||||
|
RezervniKodovi []string // jednokratni prikaz novih kodova (posle aktivacije/regeneracije)
|
||||||
|
BrojRezervnih int // koliko neiskorišćenih rezervnih kodova je preostalo
|
||||||
LokalnaTema string
|
LokalnaTema string
|
||||||
KoristiLokalnuTemu bool
|
KoristiLokalnuTemu bool
|
||||||
}
|
}
|
||||||
@@ -297,14 +299,59 @@ func (h *Handler) AdminProfil(w http.ResponseWriter, r *http.Request) {
|
|||||||
ps.Stranica = "profil"
|
ps.Stranica = "profil"
|
||||||
ps.NaslovStranice = "Moj 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{
|
h.renderujTemplate(w, "admin_profil", podaciAdminProfil{
|
||||||
PodaciStranice: ps,
|
PodaciStranice: ps,
|
||||||
TotpAktivan: svezi.TotpTajna != "",
|
TotpAktivan: svezi.TotpTajna != "",
|
||||||
|
BrojRezervnih: brojRezervnih,
|
||||||
LokalnaTema: svezi.LokalnaTema,
|
LokalnaTema: svezi.LokalnaTema,
|
||||||
KoristiLokalnuTemu: svezi.KoristiLokalnuTemu,
|
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
|
// AdminPromeniLozinku menja lozinku prijavljenog korisnika
|
||||||
func (h *Handler) AdminPromeniLozinku(w http.ResponseWriter, r *http.Request) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je uspešno uključena.")
|
// uključenjem 2FA generišemo i rezervne kodove i prikazujemo ih jednom
|
||||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
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
|
// 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)
|
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||||
return
|
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.")
|
middleware.SetFlash(w, r, h.DB, "uspeh", "Dvostepena verifikacija je isključena.")
|
||||||
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
|
||||||
|
|||||||
+39
-36
@@ -15,29 +15,30 @@ import (
|
|||||||
|
|
||||||
// Handler drži zavisnosti koje su potrebne svim handlerima
|
// Handler drži zavisnosti koje su potrebne svim handlerima
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
PutanjaBaze string
|
PutanjaBaze string
|
||||||
Artikli db.ArtikalRepository
|
Artikli db.ArtikalRepository
|
||||||
KategorijeRepo db.KategorijaRepository
|
KategorijeRepo db.KategorijaRepository
|
||||||
DobavljaciRepo db.DobavljacRepository
|
DobavljaciRepo db.DobavljacRepository
|
||||||
NabavkeRepo db.NabavkaRepository
|
NabavkeRepo db.NabavkaRepository
|
||||||
KlijentiRepo db.KlijentRepository
|
KlijentiRepo db.KlijentRepository
|
||||||
ServisRepo db.ServisRepository
|
ServisRepo db.ServisRepository
|
||||||
ServisniDeloviRepo db.ServisniDeloviRepository
|
ServisniDeloviRepo db.ServisniDeloviRepository
|
||||||
MagacinskePromeneRepo db.MagacinskePromeneRepository
|
MagacinskePromeneRepo db.MagacinskePromeneRepository
|
||||||
ProdajaRepo db.ProdajaRepository
|
ProdajaRepo db.ProdajaRepository
|
||||||
KorisniciRepo db.KorisniciRepository
|
KorisniciRepo db.KorisniciRepository
|
||||||
SesijeRepo db.SesijeRepository
|
SesijeRepo db.SesijeRepository
|
||||||
PodsetnikRepo db.PodsetnikRepository
|
PodsetnikRepo db.PodsetnikRepository
|
||||||
IzvestajRepo db.IzvestajRepository
|
IzvestajRepo db.IzvestajRepository
|
||||||
PokusajiRepo db.PokusajiPrijaveRepository
|
RezervniKodoviRepo db.RezervniKodoviRepository
|
||||||
LoginIstorijsaRepo db.LoginIstorijsaRepository
|
PokusajiRepo db.PokusajiPrijaveRepository
|
||||||
DozvoleRepo db.DozvoleRepository
|
LoginIstorijsaRepo db.LoginIstorijsaRepository
|
||||||
Verzija string
|
DozvoleRepo db.DozvoleRepository
|
||||||
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
Verzija string
|
||||||
Templates map[string]*template.Template
|
AssetV string // verzija statičkih fajlova za cache-busting (postavlja se pri pokretanju)
|
||||||
TemplatesFS fs.FS
|
Templates map[string]*template.Template
|
||||||
totpKljuc []byte // ključ za šifrovanje TOTP tajni (prosleđuje se KorisniciRepo)
|
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).
|
// mu štiti DB i sve repozitorijume od zamene u letu (obnova backupa).
|
||||||
// Zahtevi drže deljeno (read) zaključavanje preko ZakljucajCitanje, a obnova
|
// 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.
|
// totpKljuc je 32-bajtni ključ za šifrovanje TOTP tajni u mirovanju.
|
||||||
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
func Novi(baza *sql.DB, totpKljuc []byte) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
DB: baza,
|
DB: baza,
|
||||||
totpKljuc: totpKljuc,
|
totpKljuc: totpKljuc,
|
||||||
Artikli: sqlite.NoviArtikalRepo(baza),
|
Artikli: sqlite.NoviArtikalRepo(baza),
|
||||||
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
|
KategorijeRepo: sqlite.NovaKategorijaRepo(baza),
|
||||||
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
|
DobavljaciRepo: sqlite.NoviDobavljacRepo(baza),
|
||||||
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
|
NabavkeRepo: sqlite.NoviNabavkaRepo(baza),
|
||||||
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
|
KlijentiRepo: sqlite.NoviKlijentRepo(baza),
|
||||||
ServisRepo: sqlite.NoviServisRepo(baza),
|
ServisRepo: sqlite.NoviServisRepo(baza),
|
||||||
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
|
ServisniDeloviRepo: sqlite.NoviServisniDeloviRepo(baza),
|
||||||
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
|
MagacinskePromeneRepo: sqlite.NoviMagacinskePromeneRepo(baza),
|
||||||
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
|
ProdajaRepo: sqlite.NoviProdajaRepo(baza),
|
||||||
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
|
KorisniciRepo: sqlite.NoviKorisniciRepo(baza, totpKljuc),
|
||||||
SesijeRepo: sqlite.NoviSesijeRepo(baza),
|
SesijeRepo: sqlite.NoviSesijeRepo(baza),
|
||||||
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
|
PodsetnikRepo: sqlite.NoviPodsetnikRepo(baza),
|
||||||
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
|
IzvestajRepo: sqlite.NoviIzvestajRepo(baza),
|
||||||
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
|
RezervniKodoviRepo: sqlite.NoviRezervniKodoviRepo(baza),
|
||||||
LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza),
|
PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza),
|
||||||
DozvoleRepo: sqlite.NoviDozvoleRepo(baza, middleware.ImaDozvolu, middleware.SveAkcije()),
|
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.SesijeRepo = sqlite.NoviSesijeRepo(novaDB)
|
||||||
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
|
h.PodsetnikRepo = sqlite.NoviPodsetnikRepo(novaDB)
|
||||||
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
|
h.IzvestajRepo = sqlite.NoviIzvestajRepo(novaDB)
|
||||||
|
h.RezervniKodoviRepo = sqlite.NoviRezervniKodoviRepo(novaDB)
|
||||||
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
|
h.PokusajiRepo = sqlite.NoviPokusajiPrijaveRepo(novaDB)
|
||||||
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
|
h.LoginIstorijsaRepo = sqlite.NoviLoginIstorijsaRepo(novaDB)
|
||||||
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
|
h.DozvoleRepo = sqlite.NoviDozvoleRepo(novaDB, middleware.ImaDozvolu, middleware.SveAkcije())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -182,7 +183,15 @@ func (h *Handler) VerifikujTotp(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kod := r.FormValue("kod")
|
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)
|
http.Redirect(w, r, "/prijava/totp?greska=1", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestTotpPogresanKod(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Rezervni (jednokratni) kodovi za 2FA — alternativa TOTP-u kada uređaj nije dostupan.
|
||||||
|
-- Kod se čuva kao bcrypt heš; jednom iskorišćen kod se označava (iskoriscen=1).
|
||||||
|
CREATE TABLE IF NOT EXISTS rezervni_kodovi (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
korisnik_id INTEGER NOT NULL REFERENCES korisnici(id) ON DELETE CASCADE,
|
||||||
|
kod_hash TEXT NOT NULL,
|
||||||
|
iskoriscen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
datum_koriscenja DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rezervni_kodovi_korisnik ON rezervni_kodovi(korisnik_id);
|
||||||
@@ -77,6 +77,23 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rezervni (jednokratni) kodovi -->
|
||||||
|
<div style="margin-top: 18px; padding-top: 16px; border-top: 0.5px solid var(--ivica)">
|
||||||
|
{{ if .RezervniKodovi }}
|
||||||
|
<div class="poruka-uspeh" style="margin-bottom: 12px">Sačuvajte ove rezervne kodove na sigurno mesto — prikazuju se samo sada. Svaki se može upotrebiti jednom, umesto koda iz aplikacije.</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 14px; max-width: 360px">
|
||||||
|
{{ range .RezervniKodovi }}
|
||||||
|
<code style="font-size: 14px; background: var(--pozadina); padding: 8px 10px; border-radius: 6px; text-align: center; letter-spacing: 1px; color: var(--tekst-glavni)">{{ . }}</code>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div style="font-size: 13px; color: var(--tekst-sporedni); margin-bottom: 10px">Rezervni kodovi: preostalo <strong>{{ .BrojRezervnih }}</strong>. Koriste se za prijavu kada nemate pristup aplikaciji sa kodovima.</div>
|
||||||
|
{{ end }}
|
||||||
|
<form method="POST" action="/admin/profil/totp/kodovi" onsubmit="return confirm('Generisati nove rezervne kodove? Stari kodovi više neće važiti.');">
|
||||||
|
<button type="submit" style="padding: 8px 16px; background: transparent; color: var(--sb-akcent); border: 1px solid var(--sb-akcent); border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer">Regeneriši rezervne kodove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<!-- 2FA nije uključena -->
|
<!-- 2FA nije uključena -->
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px">
|
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Dvostepena verifikacija</h1>
|
<h1>Dvostepena verifikacija</h1>
|
||||||
<p class="opis">Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju.</p>
|
<p class="opis">Unesite 6-cifreni kod iz vaše aplikacije za autentifikaciju, ili jedan od rezervnih kodova (format XXXX-XXXX) ako nemate pristup aplikaciji.</p>
|
||||||
|
|
||||||
{{if eq .Greska "1"}}
|
{{if eq .Greska "1"}}
|
||||||
<div class="greska">Neispravan kod. Pokušajte ponovo.</div>
|
<div class="greska">Neispravan kod. Pokušajte ponovo.</div>
|
||||||
@@ -41,8 +41,7 @@
|
|||||||
<div class="polje">
|
<div class="polje">
|
||||||
<label for="kod">Kod za verifikaciju</label>
|
<label for="kod">Kod za verifikaciju</label>
|
||||||
<input type="text" id="kod" name="kod"
|
<input type="text" id="kod" name="kod"
|
||||||
inputmode="numeric" pattern="[0-9]{6}"
|
maxlength="12" autocomplete="one-time-code" autofocus required>
|
||||||
maxlength="6" autocomplete="one-time-code" autofocus required>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="dugme">Potvrdi</button>
|
<button type="submit" class="dugme">Potvrdi</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user