diff --git a/TESTING.md b/TESTING.md index 9f25816..e2912e0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,8 +1,8 @@ # DAL License Server — Test Checklista -## Ukupno testova: 179 +## Ukupno testova: 182 - Go unit testovi: 46 -- Playwright E2E testovi: 133 +- Playwright E2E testovi: 136 ## Pokretanje testova @@ -92,11 +92,12 @@ go test ./internal/... -v -count=1 && npx playwright test ## Playwright E2E Testovi (133) -### Login stranica (15 testova) -- [x] Prikazuje login formu sa svim elementima -- [x] Password polje ima autofocus +### Login stranica (18 testova) +- [x] Prikazuje login formu sa svim elementima (username + password) +- [x] Username polje ima autofocus - [x] Forma ima ispravnu action i method -- [x] Prijava sa ispravnom lozinkom preusmerava na dashboard +- [x] Prijava sa ispravnim username i password preusmerava na dashboard +- [x] Prijava sa pogresnim username-om prikazuje gresku - [x] Prijava sa pogresnom lozinkom prikazuje gresku - [x] Prijava sa praznom lozinkom (browser validacija) - [x] Razlicite pogresne lozinke (7 pokusaja) @@ -108,6 +109,8 @@ go test ./internal/... -v -count=1 && npx playwright test - [x] CSS je ucitan - [x] Ispravan page title - [x] Visestruki logini kreiraju razlicite sesije +- [x] Navbar prikazuje ime korisnika posle logina +- [x] Username i password labele su prikazane ### Dashboard stranica (18 testova) - [x] Prikazuje naslov Dashboard @@ -255,4 +258,4 @@ go test ./internal/... -v -count=1 && npx playwright test --- -*Poslednje azuriranje: 04.03.2026 — 179 testova (46 Go + 133 Playwright)* +*Poslednje azuriranje: 04.03.2026 — 182 testova (46 Go + 136 Playwright)* diff --git a/cmd/server/main.go b/cmd/server/main.go index 4ee8f00..dc8c6e0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "dal-license-server/internal/config" "dal-license-server/internal/handler" + "dal-license-server/internal/model" "dal-license-server/internal/repository" "dal-license-server/internal/router" "dal-license-server/internal/service" @@ -14,6 +15,7 @@ import ( "strconv" _ "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/bcrypt" ) func main() { @@ -43,6 +45,10 @@ func main() { licenseRepo := repository.NewLicenseRepo(db) activationRepo := repository.NewActivationRepo(db) auditRepo := repository.NewAuditRepo(db) + userRepo := repository.NewUserRepo(db) + + // Seed default admin user + seedDefaultAdmin(userRepo, cfg.AdminPassword) // Services licenseSvc := service.NewLicenseService(licenseRepo, auditRepo) @@ -51,7 +57,7 @@ func main() { // Handlers clientHandler := handler.NewClientHandler(activationSvc) adminHandler := handler.NewAdminHandler(licenseSvc, activationSvc, auditRepo) - dashboardHandler := handler.NewDashboardHandler(licenseSvc, activationSvc, auditRepo, "templates", cfg.AdminPassword) + dashboardHandler := handler.NewDashboardHandler(licenseSvc, activationSvc, auditRepo, userRepo, "templates") // Rate limits rlActivate, _ := strconv.Atoi(cfg.RateLimitActivate) @@ -74,7 +80,11 @@ func main() { } func runMigrations(db *sql.DB) { - files := []string{"migrations/001_create_tables.sql", "migrations/002_seed_products.sql"} + files := []string{ + "migrations/001_create_tables.sql", + "migrations/002_seed_products.sql", + "migrations/003_create_admin_users.sql", + } for _, f := range files { data, err := os.ReadFile(f) if err != nil { @@ -87,3 +97,31 @@ func runMigrations(db *sql.DB) { } } } + +func seedDefaultAdmin(userRepo *repository.UserRepo, adminPassword string) { + count, err := userRepo.Count() + if err != nil { + log.Printf("Seed admin: count error: %v", err) + return + } + if count > 0 { + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + log.Fatal("Seed admin: bcrypt error: ", err) + } + + user := &model.AdminUser{ + Username: "admin", + PasswordHash: string(hash), + FullName: "Administrator", + Active: true, + } + if err := userRepo.Create(user); err != nil { + log.Printf("Seed admin: %v (may already exist)", err) + } else { + log.Println("Default admin korisnik kreiran (username: admin)") + } +} diff --git a/go.mod b/go.mod index 4d8a95d..92c6879 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,10 @@ module dal-license-server -go 1.23.6 +go 1.24.0 require github.com/go-sql-driver/mysql v1.9.3 -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + golang.org/x/crypto v0.48.0 // indirect +) diff --git a/go.sum b/go.sum index 4bcdcfa..9102ebb 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= diff --git a/internal/handler/dashboard_handler.go b/internal/handler/dashboard_handler.go index cfedd5b..9fa1df0 100644 --- a/internal/handler/dashboard_handler.go +++ b/internal/handler/dashboard_handler.go @@ -15,19 +15,28 @@ import ( "strings" "sync" "time" + + "golang.org/x/crypto/bcrypt" ) +type SessionData struct { + UserID int64 + Username string + FullName string + Expiry time.Time +} + type DashboardHandler struct { licenses *service.LicenseService activation *service.ActivationService audit *repository.AuditRepo + userRepo *repository.UserRepo templates map[string]*template.Template - sessions map[string]time.Time + sessions map[string]*SessionData mu sync.RWMutex - password string } -func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, tmplDir, password string) *DashboardHandler { +func NewDashboardHandler(licenses *service.LicenseService, activation *service.ActivationService, audit *repository.AuditRepo, userRepo *repository.UserRepo, tmplDir string) *DashboardHandler { funcMap := template.FuncMap{ "formatDate": func(t time.Time) string { return t.Format("02.01.2006 15:04") }, "formatDateShort": func(t time.Time) string { return t.Format("02.01.2006") }, @@ -61,9 +70,9 @@ func NewDashboardHandler(licenses *service.LicenseService, activation *service.A licenses: licenses, activation: activation, audit: audit, + userRepo: userRepo, templates: make(map[string]*template.Template), - sessions: make(map[string]time.Time), - password: password, + sessions: make(map[string]*SessionData), } layoutFiles, _ := filepath.Glob(filepath.Join(tmplDir, "layout", "*.html")) @@ -95,23 +104,33 @@ func (h *DashboardHandler) render(w http.ResponseWriter, name string, data inter } } -func (h *DashboardHandler) isLoggedIn(r *http.Request) bool { +func (h *DashboardHandler) renderWithSession(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) { + if sess := h.getSession(r); sess != nil { + data["CurrentUser"] = sess.FullName + if data["CurrentUser"] == "" { + data["CurrentUser"] = sess.Username + } + } + h.render(w, name, data) +} + +func (h *DashboardHandler) getSession(r *http.Request) *SessionData { c, err := r.Cookie("dash_session") if err != nil { - return false + return nil } h.mu.RLock() defer h.mu.RUnlock() - exp, ok := h.sessions[c.Value] - if !ok || time.Now().After(exp) { - return false + sess, ok := h.sessions[c.Value] + if !ok || time.Now().After(sess.Expiry) { + return nil } - return true + return sess } func (h *DashboardHandler) RequireLogin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !h.isLoggedIn(r) { + if h.getSession(r) == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } @@ -124,9 +143,17 @@ func (h *DashboardHandler) LoginPage(w http.ResponseWriter, r *http.Request) { } func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") password := r.FormValue("password") - if password != h.password { - h.render(w, "login.html", map[string]interface{}{"Error": "Pogresna lozinka"}) + + user, err := h.userRepo.GetByUsername(username) + if err != nil || !user.Active { + h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + h.render(w, "login.html", map[string]interface{}{"Error": "Pogresno korisnicko ime ili lozinka"}) return } @@ -135,9 +162,17 @@ func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) { sid := hex.EncodeToString(b) h.mu.Lock() - h.sessions[sid] = time.Now().Add(8 * time.Hour) + h.sessions[sid] = &SessionData{ + UserID: user.ID, + Username: user.Username, + FullName: user.FullName, + Expiry: time.Now().Add(8 * time.Hour), + } h.mu.Unlock() + h.userRepo.UpdateLastLogin(user.ID) + h.audit.Log(nil, "LOGIN", clientIP(r), map[string]string{"username": user.Username}) + http.SetCookie(w, &http.Cookie{ Name: "dash_session", Value: sid, @@ -151,8 +186,12 @@ func (h *DashboardHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *DashboardHandler) Logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("dash_session"); err == nil { h.mu.Lock() + sess := h.sessions[c.Value] delete(h.sessions, c.Value) h.mu.Unlock() + if sess != nil { + h.audit.Log(nil, "LOGOUT", clientIP(r), map[string]string{"username": sess.Username}) + } } http.SetCookie(w, &http.Cookie{Name: "dash_session", MaxAge: -1, Path: "/"}) http.Redirect(w, r, "/login", http.StatusSeeOther) @@ -163,7 +202,7 @@ func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) { expiring, _ := h.licenses.ExpiringIn(7) recent, _ := h.audit.Recent(10) - h.render(w, "dashboard.html", map[string]interface{}{ + h.renderWithSession(w, r, "dashboard.html", map[string]interface{}{ "Stats": stats, "Expiring": expiring, "Recent": recent, @@ -179,7 +218,7 @@ func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) { licenses, _ := h.licenses.List(product, status, search) products, _ := h.licenses.GetProducts() - h.render(w, "licenses.html", map[string]interface{}{ + h.renderWithSession(w, r, "licenses.html", map[string]interface{}{ "Licenses": licenses, "Products": products, "Product": product, @@ -191,7 +230,7 @@ func (h *DashboardHandler) LicenseList(w http.ResponseWriter, r *http.Request) { func (h *DashboardHandler) LicenseNew(w http.ResponseWriter, r *http.Request) { products, _ := h.licenses.GetProducts() - h.render(w, "license-new.html", map[string]interface{}{ + h.renderWithSession(w, r, "license-new.html", map[string]interface{}{ "Products": products, "ActivePage": "licenses", }) @@ -231,7 +270,7 @@ func (h *DashboardHandler) LicenseCreate(w http.ResponseWriter, r *http.Request) license, err := h.licenses.Create(req, clientIP(r)) if err != nil { products, _ := h.licenses.GetProducts() - h.render(w, "license-new.html", map[string]interface{}{ + h.renderWithSession(w, r, "license-new.html", map[string]interface{}{ "Products": products, "Error": err.Error(), "ActivePage": "licenses", @@ -253,7 +292,7 @@ func (h *DashboardHandler) LicenseDetail(w http.ResponseWriter, r *http.Request) activations, _ := h.activation.ListByLicense(id) auditEntries, _ := h.audit.List(&id, 20) - h.render(w, "license-detail.html", map[string]interface{}{ + h.renderWithSession(w, r, "license-detail.html", map[string]interface{}{ "License": license, "Activations": activations, "Audit": auditEntries, @@ -276,7 +315,7 @@ func (h *DashboardHandler) LicenseRelease(w http.ResponseWriter, r *http.Request func (h *DashboardHandler) AuditPage(w http.ResponseWriter, r *http.Request) { entries, _ := h.audit.Recent(100) - h.render(w, "audit.html", map[string]interface{}{ + h.renderWithSession(w, r, "audit.html", map[string]interface{}{ "Entries": entries, "ActivePage": "audit", }) diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..310c24b --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,17 @@ +package model + +import ( + "database/sql" + "time" +) + +type AdminUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + FullName string `json:"full_name"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt sql.NullTime `json:"last_login_at"` +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..25d7840 --- /dev/null +++ b/internal/repository/user_repo.go @@ -0,0 +1,53 @@ +package repository + +import ( + "dal-license-server/internal/model" + "database/sql" + "fmt" +) + +type UserRepo struct { + db *sql.DB +} + +func NewUserRepo(db *sql.DB) *UserRepo { + return &UserRepo{db: db} +} + +func (r *UserRepo) GetByUsername(username string) (*model.AdminUser, error) { + var u model.AdminUser + err := r.db.QueryRow( + "SELECT id, username, password_hash, full_name, active, created_at, updated_at, last_login_at FROM admin_users WHERE username = ?", + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.FullName, &u.Active, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt) + if err != nil { + return nil, fmt.Errorf("get user by username: %w", err) + } + return &u, nil +} + +func (r *UserRepo) Create(u *model.AdminUser) error { + res, err := r.db.Exec( + "INSERT INTO admin_users (username, password_hash, full_name, active) VALUES (?, ?, ?, ?)", + u.Username, u.PasswordHash, u.FullName, u.Active, + ) + if err != nil { + return fmt.Errorf("create user: %w", err) + } + u.ID, _ = res.LastInsertId() + return nil +} + +func (r *UserRepo) UpdateLastLogin(id int64) error { + _, err := r.db.Exec("UPDATE admin_users SET last_login_at = NOW() WHERE id = ?", id) + if err != nil { + return fmt.Errorf("update last login: %w", err) + } + return nil +} + +func (r *UserRepo) Count() (int, error) { + var count int + err := r.db.QueryRow("SELECT COUNT(*) FROM admin_users").Scan(&count) + return count, err +} diff --git a/migrations/003_create_admin_users.sql b/migrations/003_create_admin_users.sql new file mode 100644 index 0000000..0e5fde1 --- /dev/null +++ b/migrations/003_create_admin_users.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100) NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_login_at TIMESTAMP NULL +); diff --git a/static/css/style.css b/static/css/style.css index 6f5abd1..48df43c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2,7 +2,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .navbar { background: #1a1a2e; color: #fff; padding: 0.75rem 2rem; display: flex; align-items: center; gap: 2rem; } -.nav-brand { font-size: 1.2rem; font-weight: 700; } +.nav-brand { font-size: 1.2rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; } +.nav-logo { width: 28px; height: 28px; } .nav-links { display: flex; gap: 1rem; flex: 1; } .nav-links a { color: #aaa; text-decoration: none; padding: 0.5rem 1rem; border-radius: 4px; } .nav-links a:hover, .nav-links a.active { color: #fff; background: rgba(255,255,255,0.1); } @@ -48,7 +49,8 @@ code { background: #f1f5f9; padding: 0.15rem 0.5rem; border-radius: 3px; font-si .stat-row { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.9rem; } .login-container { max-width: 400px; margin: 100px auto; padding: 2rem; } -.login-container h1 { text-align: center; margin-bottom: 2rem; } +.login-container h1 { text-align: center; margin-bottom: 2rem; display: flex; align-items: center; justify-content: center; gap: 0.6rem; } +.login-logo { width: 32px; height: 32px; } .login-form { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .form-group { margin-bottom: 1rem; } .form-group label { display: block; margin-bottom: 0.4rem; font-weight: 600; font-size: 0.9rem; } diff --git a/static/img/favicon.svg b/static/img/favicon.svg new file mode 100644 index 0000000..bb21bcf --- /dev/null +++ b/static/img/favicon.svg @@ -0,0 +1,7 @@ + diff --git a/static/img/logo.svg b/static/img/logo.svg new file mode 100644 index 0000000..0730559 --- /dev/null +++ b/static/img/logo.svg @@ -0,0 +1,10 @@ + diff --git a/templates/layout/base.html b/templates/layout/base.html index 25f7f65..e465c39 100644 --- a/templates/layout/base.html +++ b/templates/layout/base.html @@ -4,18 +4,20 @@