From d68aaba787d4106dbe0d968330d2f36e0d15fff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Wed, 3 Jun 2026 22:05:00 +0200 Subject: [PATCH] =?UTF-8?q?Evidencija=20prijava=20=E2=80=94=20login=5Fisto?= =?UTF-8?q?rija=20tabela,=20logovanje=20svih=20poku=C5=A1aja,=20stranica?= =?UTF-8?q?=20istorije=20po=20korisniku,=20WebAuthn=20shema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ntech/main.go | 1 + internal/db/repository.go | 6 ++ internal/db/sqlite/login_istorija.go | 74 ++++++++++++++++++ internal/handler/admin.go | 44 +++++++++++ internal/handler/handler.go | 8 +- internal/handler/prijava.go | 13 +++- internal/model/login_pokusaj.go | 14 ++++ migrations/019_login_istorija.sql | 11 +++ migrations/020_webauthn.sql | 9 +++ web/templates/stranice/admin_korisnici.html | 6 +- .../stranice/admin_login_istorija.html | 77 +++++++++++++++++++ 11 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 internal/db/sqlite/login_istorija.go create mode 100644 internal/model/login_pokusaj.go create mode 100644 migrations/019_login_istorija.sql create mode 100644 migrations/020_webauthn.sql create mode 100644 web/templates/stranice/admin_login_istorija.html diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 526fd40..1771678 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -165,6 +165,7 @@ func main() { // admin rute r.Get("/admin/korisnici", h.AdminKorisnici) + r.Get("/admin/korisnici/{id}/istorija", h.AdminLoginIstorija) r.Post("/admin/korisnici/novi", h.AdminSacuvajKorisnika) r.Post("/admin/korisnici/{id}/aktivan", h.AdminToggleAktivan) r.Post("/admin/korisnici/{id}/uloga", h.AdminPromeniUlogu) diff --git a/internal/db/repository.go b/internal/db/repository.go index 2b14de2..35bb962 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -111,6 +111,12 @@ type PokusajiPrijaveRepository interface { ObrisiStare(ctx context.Context, pre time.Time) error } +// LoginIstorijsaRepository definiše operacije nad evidencijom prijava +type LoginIstorijsaRepository interface { + Zabeleži(ctx context.Context, korisnikID *int64, ip, userAgent, razlog string, uspeh bool) error + ListaZaKorisnika(ctx context.Context, korisnikID int64, limit int) ([]*model.LoginPokusaj, error) +} + // PodsetnikRepository definiše operacije nad podsetnicima type PodsetnikRepository interface { Lista(ctx context.Context, filter PodsetnikFilter) ([]model.Podsetnik, error) diff --git a/internal/db/sqlite/login_istorija.go b/internal/db/sqlite/login_istorija.go new file mode 100644 index 0000000..d6e9b69 --- /dev/null +++ b/internal/db/sqlite/login_istorija.go @@ -0,0 +1,74 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "ntech/internal/model" +) + +// LoginIstorijsaRepo implementira db.LoginIstorijsaRepository nad SQLite +type LoginIstorijsaRepo struct { + db *sql.DB +} + +// NoviLoginIstorijsaRepo kreira novi repozitorijum za evidenciju prijava +func NoviLoginIstorijsaRepo(db *sql.DB) *LoginIstorijsaRepo { + return &LoginIstorijsaRepo{db: db} +} + +// Zabeleži upisuje pokušaj prijave u evidenciju +func (r *LoginIstorijsaRepo) Zabeleži(ctx context.Context, korisnikID *int64, ip, userAgent, razlog string, uspeh bool) error { + u := 0 + if uspeh { + u = 1 + } + var kid sql.NullInt64 + if korisnikID != nil { + kid = sql.NullInt64{Valid: true, Int64: *korisnikID} + } + _, err := r.db.ExecContext(ctx, + "INSERT INTO login_istorija (korisnik_id, ip, user_agent, uspeh, razlog) VALUES (?, ?, ?, ?, ?)", + kid, ip, userAgent, u, razlog, + ) + if err != nil { + return fmt.Errorf("ntech: LoginIstorijsaRepo.Zabeleži: %w", err) + } + return nil +} + +// ListaZaKorisnika vraća poslednjih N zapisa za datog korisnika +func (r *LoginIstorijsaRepo) ListaZaKorisnika(ctx context.Context, korisnikID int64, limit int) ([]*model.LoginPokusaj, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT id, korisnik_id, ip, user_agent, uspeh, razlog, vreme + FROM login_istorija + WHERE korisnik_id = ? + ORDER BY vreme DESC + LIMIT ?`, + korisnikID, limit, + ) + if err != nil { + return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: %w", err) + } + defer rows.Close() + + var lista []*model.LoginPokusaj + for rows.Next() { + p := &model.LoginPokusaj{} + var kid sql.NullInt64 + var u int + var s string + if err := rows.Scan(&p.ID, &kid, &p.IP, &p.UserAgent, &u, &p.Razlog, &s); err != nil { + return nil, fmt.Errorf("ntech: LoginIstorijsaRepo.ListaZaKorisnika: scan: %w", err) + } + if kid.Valid { + p.KorisnikID = &kid.Int64 + } + p.Uspeh = u == 1 + p.Vreme, _ = time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC) + lista = append(lista, p) + } + return lista, rows.Err() +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 9af4b14..d7d2357 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -20,6 +20,12 @@ type podaciAdminKorisnici struct { Sacuvano bool } +type podaciLoginIstorija struct { + model.PodaciStranice + PrikazKorisnik model.Korisnik + Istorija []*model.LoginPokusaj +} + type podaciAdminProfil struct { model.PodaciStranice Greska string @@ -333,6 +339,44 @@ func (h *Handler) AdminTotpDeaktivacija(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/admin/profil?sacuvano=totp_off", http.StatusSeeOther) } +// AdminLoginIstorija prikazuje evidenciju prijava za datog korisnika +func (h *Handler) AdminLoginIstorija(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if !middleware.JeAdmin(k) { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + + id, err := parseID(chi.URLParam(r, "id")) + if err != nil { + http.Redirect(w, r, "/admin/korisnici", http.StatusSeeOther) + return + } + + korisnik, err := h.KorisniciRepo.DohvatiPoID(r.Context(), id) + if err != nil { + http.Error(w, "Korisnik nije pronađen", http.StatusNotFound) + return + } + + istorija, err := h.LoginIstorijsaRepo.ListaZaKorisnika(r.Context(), id, 50) + if err != nil { + http.Error(w, "Greška pri učitavanju istorije", http.StatusInternalServerError) + return + } + + podesavanja, _ := sqlite.DohvatiSvaPodesavanja(r.Context(), h.DB) + ps := h.popuniPodaciStranice(r, podesavanja) + ps.Stranica = "admin" + ps.NaslovStranice = "Istorija prijava — " + korisnik.KorisnickoIme + + h.renderujTemplate(w, "admin_login_istorija", podaciLoginIstorija{ + PodaciStranice: ps, + PrikazKorisnik: *korisnik, + Istorija: istorija, + }) +} + // parseBoolForm čita boolean vrednost iz forme func parseBoolForm(s string) bool { b, _ := strconv.ParseBool(s) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index c4af689..e874056 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -24,8 +24,9 @@ type Handler struct { KorisniciRepo db.KorisniciRepository SesijeRepo db.SesijeRepository PodsetniciFRepo db.PodsetnikRepository - PokusajiRepo db.PokusajiPrijaveRepository - Verzija string + PokusajiRepo db.PokusajiPrijaveRepository + LoginIstorijsaRepo db.LoginIstorijsaRepository + Verzija string Templates map[string]*template.Template } @@ -43,7 +44,8 @@ func Novi(baza *sql.DB) *Handler { KorisniciRepo: sqlite.NoviKorisniciRepo(baza), SesijeRepo: sqlite.NoviSesijeRepo(baza), PodsetniciFRepo: sqlite.NoviPodsetnikRepo(baza), - PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), + PokusajiRepo: sqlite.NoviPokusajiPrijaveRepo(baza), + LoginIstorijsaRepo: sqlite.NoviLoginIstorijsaRepo(baza), } } diff --git a/internal/handler/prijava.go b/internal/handler/prijava.go index af5ffab..7c79e7e 100644 --- a/internal/handler/prijava.go +++ b/internal/handler/prijava.go @@ -51,6 +51,7 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) { if n >= maxNeuspehaPrijave { if preostalo, zaklj := h.preostaloBruteforce(r.Context(), ip, od); zaklj { auth.LogZaklucano(ip, korisnickoIme) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "ip_zaklucano", false) h.renderujStandalone(w, "prijava", map[string]any{ "Greska": "zakljucano", "Preostalo": preostalo, @@ -62,21 +63,31 @@ func (h *Handler) Prijava(w http.ResponseWriter, r *http.Request) { } korisnik, err := h.KorisniciRepo.DohvatiPoImenu(r.Context(), korisnickoIme) - if err != nil || !korisnik.Aktivan { + if err != nil { _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), nil, ip, r.UserAgent(), "korisnik_ne_postoji", false) auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_user") http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) return } + if !korisnik.Aktivan { + _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "nalog_neaktivan", false) + auth.LogNeuspehPrijave(ip, korisnickoIme, "nalog_neaktivan") + http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) + return + } if !auth.ProveriLozinku(korisnik.LozinkaHash, lozinka) { _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, false) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "pogrešna_lozinka", false) auth.LogNeuspehPrijave(ip, korisnickoIme, "wrong_password") http.Redirect(w, r, "/prijava?greska=1", http.StatusSeeOther) return } _ = h.PokusajiRepo.Zabeleži(r.Context(), ip, korisnickoIme, true) + _ = h.LoginIstorijsaRepo.Zabeleži(r.Context(), &korisnik.ID, ip, r.UserAgent(), "", true) auth.LogUspehPrijave(ip, korisnickoIme) token := auth.GenerisiToken() diff --git a/internal/model/login_pokusaj.go b/internal/model/login_pokusaj.go new file mode 100644 index 0000000..e0dd4d1 --- /dev/null +++ b/internal/model/login_pokusaj.go @@ -0,0 +1,14 @@ +package model + +import "time" + +// LoginPokusaj predstavlja jedan zapis iz evidencije prijava +type LoginPokusaj struct { + ID int64 + KorisnikID *int64 // nil ako korisnik nije pronađen + IP string + UserAgent string + Uspeh bool + Razlog string + Vreme time.Time +} diff --git a/migrations/019_login_istorija.sql b/migrations/019_login_istorija.sql new file mode 100644 index 0000000..612046e --- /dev/null +++ b/migrations/019_login_istorija.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS login_istorija ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + korisnik_id INTEGER, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + uspeh INTEGER NOT NULL DEFAULT 0, + razlog TEXT NOT NULL DEFAULT '', + vreme DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (korisnik_id) REFERENCES korisnici(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_login_istorija_korisnik ON login_istorija(korisnik_id, vreme); diff --git a/migrations/020_webauthn.sql b/migrations/020_webauthn.sql new file mode 100644 index 0000000..10ceb6d --- /dev/null +++ b/migrations/020_webauthn.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS webauthn_kredencijali ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + korisnik_id INTEGER NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + datum_kreiranja DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (korisnik_id) REFERENCES korisnici(id) ON DELETE CASCADE +); diff --git a/web/templates/stranice/admin_korisnici.html b/web/templates/stranice/admin_korisnici.html index 7964942..c5e1807 100644 --- a/web/templates/stranice/admin_korisnici.html +++ b/web/templates/stranice/admin_korisnici.html @@ -105,7 +105,11 @@ {{.DatumKreiranja.Format "02.01.2006."}} - + + + Istorija prijava + {{if ne .KorisnickoIme $.KorisnikIme}}