From 4cafd9848f19408f421f68bbc1b075508687d093 Mon Sep 17 00:00:00 2001 From: nahakubuilde Date: Tue, 26 Aug 2025 07:46:01 +0100 Subject: [PATCH] add loger, access log and bans/whitelist --- internal/auth/service.go | 175 ++++++++++++------- internal/config/config.go | 43 +++++ internal/handlers/auth.go | 87 ++++++++++ internal/handlers/handlers.go | 307 +++++++++++++++++++++++++++++++++ internal/handlers/settings.go | 72 ++++++++ internal/server/middleware.go | 83 +++++++++ internal/server/server.go | 30 +++- web/templates/admin_logs.html | 312 ++++++++++++++++++++++++++++++++++ web/templates/base.html | 6 + web/templates/settings.html | 108 ++++++++++++ 10 files changed, 1163 insertions(+), 60 deletions(-) create mode 100644 web/templates/admin_logs.html diff --git a/internal/auth/service.go b/internal/auth/service.go index 3176cfa..9b24b1b 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -142,65 +142,122 @@ func Open(cfg *config.Config) (*Service, error) { } func (s *Service) migrate() error { - stmts := []string{ - `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1, - email_confirmed INTEGER NOT NULL DEFAULT 0, - mfa_secret TEXT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )` , - `CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL - )` , - `CREATE TABLE IF NOT EXISTS user_groups ( - user_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - PRIMARY KEY(user_id, group_id), - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE - )` , - `CREATE TABLE IF NOT EXISTS permissions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_id INTEGER NOT NULL, - path_prefix TEXT NOT NULL, - can_read INTEGER NOT NULL DEFAULT 1, - can_write INTEGER NOT NULL DEFAULT 0, - can_delete INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE - )` , - `CREATE TABLE IF NOT EXISTS email_verification_tokens ( - user_id INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - PRIMARY KEY(user_id), - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - )` , - `CREATE TABLE IF NOT EXISTS password_reset_tokens ( - user_id INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - PRIMARY KEY(user_id), - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - )` , - `CREATE TABLE IF NOT EXISTS mfa_enrollments ( - user_id INTEGER PRIMARY KEY, - secret TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - )` , - } - for _, stmt := range stmts { - if _, err := s.DB.Exec(stmt); err != nil { - return err - } - } - return nil + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + email_confirmed INTEGER NOT NULL DEFAULT 0, + mfa_secret TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )` , + `CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL + )` , + `CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY(user_id, group_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE + )` , + `CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + path_prefix TEXT NOT NULL, + can_read INTEGER NOT NULL DEFAULT 1, + can_write INTEGER NOT NULL DEFAULT 0, + can_delete INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE + )` , + `CREATE TABLE IF NOT EXISTS email_verification_tokens ( + user_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + PRIMARY KEY(user_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )` , + `CREATE TABLE IF NOT EXISTS password_reset_tokens ( + user_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + PRIMARY KEY(user_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )` , + `CREATE TABLE IF NOT EXISTS mfa_enrollments ( + user_id INTEGER PRIMARY KEY, + secret TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )` , + // Access logs for all requests + `CREATE TABLE IF NOT EXISTS access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL, + ip TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + user_agent TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL + )` , + `CREATE INDEX IF NOT EXISTS idx_access_logs_created_at ON access_logs(created_at)` , + `CREATE INDEX IF NOT EXISTS idx_access_logs_user_id ON access_logs(user_id)` , + `CREATE INDEX IF NOT EXISTS idx_access_logs_ip ON access_logs(ip)` , + + // Error logs for server-side errors + `CREATE TABLE IF NOT EXISTS error_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL, + ip TEXT NULL, + path TEXT NULL, + message TEXT NOT NULL, + stack TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL + )` , + `CREATE INDEX IF NOT EXISTS idx_error_logs_created_at ON error_logs(created_at)` , + + // Failed login attempts (both password and MFA tracked separately) + `CREATE TABLE IF NOT EXISTS failed_logins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + user_id INTEGER NULL, + username TEXT NULL, + type TEXT NOT NULL CHECK(type IN ('password','mfa')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL + )` , + `CREATE INDEX IF NOT EXISTS idx_failed_logins_ip_created ON failed_logins(ip, created_at)` , + `CREATE INDEX IF NOT EXISTS idx_failed_logins_type_created ON failed_logins(type, created_at)` , + + // IP bans and whitelist + `CREATE TABLE IF NOT EXISTS ip_bans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL UNIQUE, + reason TEXT NULL, + until DATETIME NULL, + permanent INTEGER NOT NULL DEFAULT 0, + whitelisted INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )` , + `CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip)` , + `CREATE INDEX IF NOT EXISTS idx_ip_bans_whitelist ON ip_bans(whitelisted)` , + `CREATE INDEX IF NOT EXISTS idx_ip_bans_permanent ON ip_bans(permanent)` , + } + for _, stmt := range stmts { + if _, err := s.DB.Exec(stmt); err != nil { + return err + } + } + return nil } func (s *Service) ensureDefaultAdmin() error { diff --git a/internal/config/config.go b/internal/config/config.go index c8b4486..9eff153 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,13 @@ type Config struct { SMTPPassword string SMTPSender string SMTPUseTLS bool + + // Security settings (failed-login thresholds and auto-ban config) + PwdFailuresThreshold int + MFAFailuresThreshold int + FailuresWindowMinutes int + AutoBanDurationHours int + AutoBanPermanent bool } var defaultConfig = map[string]map[string]string{ @@ -96,6 +103,13 @@ var defaultConfig = map[string]map[string]string{ "SMTP_SENDER": "", "SMTP_USE_TLS": "true", }, + "SECURITY": { + "PWD_FAILURES_THRESHOLD": "5", + "MFA_FAILURES_THRESHOLD": "10", + "FAILURES_WINDOW_MINUTES": "30", + "AUTO_BAN_DURATION_HOURS": "12", + "AUTO_BAN_PERMANENT": "false", + }, } func Load() (*Config, error) { @@ -194,6 +208,14 @@ func Load() (*Config, error) { config.SMTPSender = emailSection.Key("SMTP_SENDER").String() config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool() + // Load SECURITY section + secSection := cfg.Section("SECURITY") + config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int() + config.MFAFailuresThreshold, _ = secSection.Key("MFA_FAILURES_THRESHOLD").Int() + config.FailuresWindowMinutes, _ = secSection.Key("FAILURES_WINDOW_MINUTES").Int() + config.AutoBanDurationHours, _ = secSection.Key("AUTO_BAN_DURATION_HOURS").Int() + config.AutoBanPermanent, _ = secSection.Key("AUTO_BAN_PERMANENT").Bool() + return config, nil } @@ -362,6 +384,27 @@ func (c *Config) SaveSetting(section, key, value string) error { case "SMTP_USE_TLS": c.SMTPUseTLS = value == "true" } + case "SECURITY": + switch key { + case "PWD_FAILURES_THRESHOLD": + if v, err := strconv.Atoi(value); err == nil { + c.PwdFailuresThreshold = v + } + case "MFA_FAILURES_THRESHOLD": + if v, err := strconv.Atoi(value); err == nil { + c.MFAFailuresThreshold = v + } + case "FAILURES_WINDOW_MINUTES": + if v, err := strconv.Atoi(value); err == nil { + c.FailuresWindowMinutes = v + } + case "AUTO_BAN_DURATION_HOURS": + if v, err := strconv.Atoi(value); err == nil { + c.AutoBanDurationHours = v + } + case "AUTO_BAN_PERMANENT": + c.AutoBanPermanent = value == "true" + } } return cfg.SaveTo(configPath) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 05c36ee..320c792 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -30,6 +30,87 @@ func (h *Handlers) LoginPage(c *gin.Context) { }) } +// --- Failed login tracking and automatic IP bans --- + +const ( + defaultPwdFailuresThreshold = 5 + defaultMFAFailuresThreshold = 10 + defaultFailuresWindow = 30 * time.Minute + defaultBanDuration = 12 * time.Hour +) + +// recordFailedAttempt logs a failed attempt and applies IP ban if thresholds exceeded. +func (h *Handlers) recordFailedAttempt(c *gin.Context, typ, username string, userID *int64) { + ip := c.ClientIP() + var uid interface{} + if userID != nil { + uid = *userID + } + // Insert failed attempt (best-effort) + _, _ = h.authSvc.DB.Exec(`INSERT INTO failed_logins (ip, user_id, username, type) VALUES (?,?,?,?)`, ip, uid, username, typ) + + // Determine threshold using config overrides (fallback to defaults when zero) + threshold := h.config.PwdFailuresThreshold + if threshold <= 0 { + threshold = defaultPwdFailuresThreshold + } + if typ == "mfa" { + t2 := h.config.MFAFailuresThreshold + if t2 <= 0 { + t2 = defaultMFAFailuresThreshold + } + threshold = t2 + } + // Count recent failures for this IP and type + var cnt int + // Window from config (minutes) + windowMinutes := h.config.FailuresWindowMinutes + if windowMinutes <= 0 { + windowMinutes = int(defaultFailuresWindow / time.Minute) + } + cutoff := time.Now().Add(-time.Duration(windowMinutes) * time.Minute) + _ = h.authSvc.DB.QueryRow(`SELECT COUNT(1) FROM failed_logins WHERE ip = ? AND type = ? AND created_at >= ?`, ip, typ, cutoff).Scan(&cnt) + if cnt >= threshold { + // Duration/permanence from config + banHours := h.config.AutoBanDurationHours + if banHours <= 0 { + banHours = int(defaultBanDuration / time.Hour) + } + makePermanent := h.config.AutoBanPermanent + var until interface{} + if makePermanent { + until = nil + } else { + until = time.Now().Add(time.Duration(banHours) * time.Hour) + } + reason := fmt.Sprintf("auto-ban: %s failures=%d within %d minutes", typ, cnt, windowMinutes) + // Upsert ban unless whitelisted or permanent existing + if makePermanent { + _, _ = h.authSvc.DB.Exec(` + INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, created_at, updated_at) + VALUES (?, ?, NULL, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(ip) DO UPDATE SET + reason=excluded.reason, + until=NULL, + permanent=1, + updated_at=CURRENT_TIMESTAMP + WHERE ip_bans.whitelisted = 0 + `, ip, reason) + } else { + _, _ = h.authSvc.DB.Exec(` + INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, created_at, updated_at) + VALUES (?, ?, ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(ip) DO UPDATE SET + reason=excluded.reason, + until=excluded.until, + permanent=0, + updated_at=CURRENT_TIMESTAMP + WHERE ip_bans.whitelisted = 0 AND ip_bans.permanent = 0 + `, ip, reason, until) + } + } +} + // isAllDigits returns true if s consists only of ASCII digits 0-9 func isAllDigits(s string) bool { if s == "" { @@ -65,6 +146,8 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) { code := strings.TrimSpace(c.PostForm("code")) if len(code) != 6 || !isAllDigits(code) { token, _ := c.Get("csrf_token") + // record failed MFA attempt + h.recordFailedAttempt(c, "mfa", "", nil) c.HTML(http.StatusUnauthorized, "mfa", gin.H{ "app_name": h.config.AppName, "csrf_token": token, @@ -84,11 +167,13 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) { uid, _ := uidAny.(int64) var secret string if err := h.authSvc.DB.QueryRow(`SELECT mfa_secret FROM users WHERE id = ?`, uid).Scan(&secret); err != nil || secret == "" { + h.recordFailedAttempt(c, "mfa", "", &uid) c.HTML(http.StatusUnauthorized, "mfa", gin.H{"error": "MFA not enabled", "Page": "mfa", "ContentTemplate": "mfa_content", "ScriptsTemplate": "mfa_scripts", "app_name": h.config.AppName}) return } if !verifyTOTP(secret, code, time.Now()) { token, _ := c.Get("csrf_token") + h.recordFailedAttempt(c, "mfa", "", &uid) c.HTML(http.StatusUnauthorized, "mfa", gin.H{ "app_name": h.config.AppName, "csrf_token": token, @@ -227,6 +312,8 @@ func (h *Handlers) LoginPost(c *gin.Context) { user, err := h.authSvc.Authenticate(username, password) if err != nil { token, _ := c.Get("csrf_token") + // record failed password attempt + h.recordFailedAttempt(c, "password", username, nil) c.HTML(http.StatusUnauthorized, "login", gin.H{ "app_name": h.config.AppName, "csrf_token": token, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 5c0bd43..22dc55e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "crypto/rand" @@ -32,6 +33,312 @@ type Handlers struct { authSvc *auth.Service } +// AdminBanIP bans an IP (temporary via hours or permanent) +func (h *Handlers) AdminBanIP(c *gin.Context) { + ip := strings.TrimSpace(c.PostForm("ip")) + if ip == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) + return + } + reason := strings.TrimSpace(c.PostForm("reason")) + permStr := strings.TrimSpace(c.PostForm("permanent")) + hoursStr := strings.TrimSpace(c.PostForm("hours")) + permanent := 0 + if permStr == "1" || strings.EqualFold(permStr, "true") { + permanent = 1 + } + + var until sql.NullTime + if permanent == 0 && hoursStr != "" { + if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 { + t := time.Now().Add(time.Duration(hInt) * time.Hour) + until = sql.NullTime{Time: t, Valid: true} + } + } + + // Upsert ban + if permanent == 1 { + _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) + VALUES (?, ?, NULL, 1, 0, CURRENT_TIMESTAMP) + ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=NULL, permanent=1, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason)) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return } + } else { + _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) + VALUES (?, ?, ?, 0, 0, CURRENT_TIMESTAMP) + ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=excluded.until, permanent=0, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason), until) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return } + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// AdminUnbanIP removes a ban entry (and whitelist) for an IP +func (h *Handlers) AdminUnbanIP(c *gin.Context) { + ip := strings.TrimSpace(c.PostForm("ip")) + if ip == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) + return + } + if _, err := h.authSvc.DB.Exec(`DELETE FROM ip_bans WHERE ip = ?`, ip); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// AdminWhitelistIP sets or clears whitelist for an IP +func (h *Handlers) AdminWhitelistIP(c *gin.Context) { + ip := strings.TrimSpace(c.PostForm("ip")) + if ip == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) + return + } + val := strings.TrimSpace(c.PostForm("value")) + w := 0 + if val == "1" || strings.EqualFold(val, "true") { w = 1 } + // Ensure row exists and update flags + _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) + VALUES (?, NULL, NULL, 0, ?, CURRENT_TIMESTAMP) + ON CONFLICT(ip) DO UPDATE SET whitelisted=excluded.whitelisted, permanent=CASE WHEN excluded.whitelisted=1 THEN 0 ELSE permanent END, until=CASE WHEN excluded.whitelisted=1 THEN NULL ELSE until END, updated_at=CURRENT_TIMESTAMP`, ip, w) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + + +// helper: turn empty string to sql.NullString +func nullIfEmpty(s string) sql.NullString { + if strings.TrimSpace(s) == "" { return sql.NullString{Valid: false} } + return sql.NullString{String: s, Valid: true} +} + +// AdminLogsPage shows recent access, error, failed login, and IP ban entries +func (h *Handlers) AdminLogsPage(c *gin.Context) { + + // Parse filters + ipFilter := strings.TrimSpace(c.Query("ip")) + hoursStr := strings.TrimSpace(c.Query("hours")) + limitStr := strings.TrimSpace(c.Query("limit")) + bansFilter := strings.TrimSpace(c.Query("bans")) // all|active|perma|whitelist + + // Defaults + var lastSince *time.Time + if hoursStr != "" { + if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 { + t := time.Now().Add(-time.Duration(hInt) * time.Hour) + lastSince = &t + } + } + lim := 100 + if limitStr != "" { + if v, err := strconv.Atoi(limitStr); err == nil { + if v <= 0 { + lim = 100 + } else if v > 500 { + lim = 500 + } else { + lim = v + } + } + } + if bansFilter == "" { + bansFilter = "all" + } + + // Query recent entries with filters + type accessRow struct { + CreatedAt time.Time + IP string + Method string + Path string + Status int + Duration int64 + UserAgent string + UserID sql.NullInt64 + Username sql.NullString + } + type errorRow struct { + CreatedAt time.Time + IP sql.NullString + Path sql.NullString + Message string + } + type failedRow struct { + CreatedAt time.Time + IP string + Username sql.NullString + Type string + UserID sql.NullInt64 + } + type banRow struct { + IP string + Reason sql.NullString + Until sql.NullTime + Permanent int + Whitelisted int + UpdatedAt time.Time + } + + // Access logs + access := []accessRow{} + { + sb := strings.Builder{} + sb.WriteString("SELECT al.created_at, al.ip, al.method, al.path, al.status, al.duration_ms, al.user_agent, al.user_id, u.username FROM access_logs al LEFT JOIN users u ON u.id = al.user_id WHERE 1=1") + args := []any{} + if lastSince != nil { + sb.WriteString(" AND al.created_at >= ?") + args = append(args, *lastSince) + } + if ipFilter != "" { + sb.WriteString(" AND al.ip LIKE ?") + args = append(args, "%"+ipFilter+"%") + } + sb.WriteString(" ORDER BY al.created_at DESC LIMIT ") + sb.WriteString(strconv.Itoa(lim)) + if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { + defer rows.Close() + for rows.Next() { + var r accessRow + if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Method, &r.Path, &r.Status, &r.Duration, &r.UserAgent, &r.UserID, &r.Username); err == nil { + access = append(access, r) + } + } + } + } + + // Error logs + errors := []errorRow{} + { + sb := strings.Builder{} + sb.WriteString("SELECT created_at, ip, path, message FROM error_logs WHERE 1=1") + args := []any{} + if lastSince != nil { + sb.WriteString(" AND created_at >= ?") + args = append(args, *lastSince) + } + if ipFilter != "" { + sb.WriteString(" AND ip IS NOT NULL AND ip LIKE ?") + args = append(args, "%"+ipFilter+"%") + } + sb.WriteString(" ORDER BY created_at DESC LIMIT ") + sb.WriteString(strconv.Itoa(lim)) + if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { + defer rows.Close() + for rows.Next() { + var r errorRow + if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Path, &r.Message); err == nil { + errors = append(errors, r) + } + } + } + } + + // Failed logins + failed := []failedRow{} + { + sb := strings.Builder{} + sb.WriteString("SELECT created_at, ip, username, type, user_id FROM failed_logins WHERE 1=1") + args := []any{} + if lastSince != nil { + sb.WriteString(" AND created_at >= ?") + args = append(args, *lastSince) + } + if ipFilter != "" { + sb.WriteString(" AND ip LIKE ?") + args = append(args, "%"+ipFilter+"%") + } + sb.WriteString(" ORDER BY created_at DESC LIMIT ") + sb.WriteString(strconv.Itoa(lim)) + if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { + defer rows.Close() + for rows.Next() { + var r failedRow + if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Username, &r.Type, &r.UserID); err == nil { + failed = append(failed, r) + } + } + } + } + + // IP bans + bans := []banRow{} + { + sb := strings.Builder{} + sb.WriteString("SELECT ip, reason, until, permanent, whitelisted, updated_at FROM ip_bans WHERE 1=1") + args := []any{} + switch strings.ToLower(bansFilter) { + case "perma", "perm", "permanent": + sb.WriteString(" AND permanent = 1") + case "whitelist", "whitelisted": + sb.WriteString(" AND whitelisted = 1") + case "active": + // Active means currently in effect and not whitelisted + sb.WriteString(" AND whitelisted = 0 AND (permanent = 1 OR (until IS NOT NULL AND until > CURRENT_TIMESTAMP))") + default: + // all + } + if ipFilter != "" { + sb.WriteString(" AND ip LIKE ?") + args = append(args, "%"+ipFilter+"%") + } + sb.WriteString(" ORDER BY updated_at DESC LIMIT ") + sb.WriteString(strconv.Itoa(lim)) + if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { + defer rows.Close() + for rows.Next() { + var r banRow + if err := rows.Scan(&r.IP, &r.Reason, &r.Until, &r.Permanent, &r.Whitelisted, &r.UpdatedAt); err == nil { + bans = append(bans, r) + } + } + } + } + + c.HTML(http.StatusOK, "admin_logs", gin.H{ + "app_name": h.config.AppName, + "NoSidebar": true, + "breadcrumbs": []gin.H{{"Name": "/", "URL": "/"}, {"Name": "Admin", "URL": "/editor/admin"}, {"Name": "Logs", "URL": ""}}, + "Page": "admin_logs", + "AccessLogs": access, + "ErrorLogs": errors, + "FailedLogins": failed, + "IPBans": bans, + // Echo filters back to template + "FilterIP": ipFilter, + "FilterHours": hoursStr, + "FilterLimit": lim, + "FilterBans": bansFilter, + }) +} + +// AdminClearAccessLogs clears access logs based on days parameter: 0=all, default 7 +func (h *Handlers) AdminClearAccessLogs(c *gin.Context) { + daysStr := strings.TrimSpace(c.PostForm("days")) + days := 7 + if daysStr != "" { + if v, err := strconv.Atoi(daysStr); err == nil && v >= 0 { + days = v + } + } + var ( + query string + args []any + ) + if days == 0 { + query = `DELETE FROM access_logs` + } else { + query = `DELETE FROM access_logs WHERE created_at < DATETIME('now', ? || ' days')` + // negative days for SQLite modifier, e.g., '-7 days' + args = append(args, fmt.Sprintf("-%d", days)) + } + if _, err := h.authSvc.DB.Exec(query, args...); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + // ProfilePage renders the user profile page for the signed-in user func (h *Handlers) ProfilePage(c *gin.Context) { // Must be authenticated; middleware ensures user_id is set diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index ddeeb00..c9d2f76 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -207,3 +207,75 @@ func boolToStr(b bool) string { } return "false" } + +// --- Security (IP Ban & Thresholds) Settings --- + +// GetSecuritySettingsHandler returns current security-related config +func (h *Handlers) GetSecuritySettingsHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "pwd_failures_threshold": h.config.PwdFailuresThreshold, + "mfa_failures_threshold": h.config.MFAFailuresThreshold, + "failures_window_minutes": h.config.FailuresWindowMinutes, + "auto_ban_duration_hours": h.config.AutoBanDurationHours, + "auto_ban_permanent": h.config.AutoBanPermanent, + }) +} + +// PostSecuritySettingsHandler validates and saves security-related config +func (h *Handlers) PostSecuritySettingsHandler(c *gin.Context) { + pwdStr := strings.TrimSpace(c.PostForm("pwd_failures_threshold")) + mfaStr := strings.TrimSpace(c.PostForm("mfa_failures_threshold")) + winStr := strings.TrimSpace(c.PostForm("failures_window_minutes")) + durStr := strings.TrimSpace(c.PostForm("auto_ban_duration_hours")) + permStr := strings.TrimSpace(c.PostForm("auto_ban_permanent")) + + // basic validation + if pwdStr == "" || mfaStr == "" || winStr == "" || durStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "All numeric fields are required"}) + return + } + if _, err := strconv.Atoi(pwdStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid password failures threshold"}) + return + } + if _, err := strconv.Atoi(mfaStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid MFA failures threshold"}) + return + } + if _, err := strconv.Atoi(winStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid failures window (minutes)"}) + return + } + if _, err := strconv.Atoi(durStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid auto-ban duration (hours)"}) + return + } + + // normalize perm + perm := strings.EqualFold(permStr, "true") || permStr == "1" || strings.EqualFold(permStr, "on") + permStr = boolToStr(perm) + + // Save values + if err := h.config.SaveSetting("SECURITY", "PWD_FAILURES_THRESHOLD", pwdStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save PWD_FAILURES_THRESHOLD"}) + return + } + if err := h.config.SaveSetting("SECURITY", "MFA_FAILURES_THRESHOLD", mfaStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save MFA_FAILURES_THRESHOLD"}) + return + } + if err := h.config.SaveSetting("SECURITY", "FAILURES_WINDOW_MINUTES", winStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save FAILURES_WINDOW_MINUTES"}) + return + } + if err := h.config.SaveSetting("SECURITY", "AUTO_BAN_DURATION_HOURS", durStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save AUTO_BAN_DURATION_HOURS"}) + return + } + if err := h.config.SaveSetting("SECURITY", "AUTO_BAN_PERMANENT", permStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save AUTO_BAN_PERMANENT"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 789a86e..736c3d6 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -2,8 +2,10 @@ package server import ( "crypto/rand" + "database/sql" "encoding/base64" "net/http" + "time" "github.com/gin-gonic/gin" ) @@ -105,3 +107,84 @@ func (s *Server) RequireAdmin() gin.HandlerFunc { c.Next() } } + +// AccessLogger logs all requests to the access_logs table after handling. +func (s *Server) AccessLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + duration := time.Since(start) + status := c.Writer.Status() + ip := c.ClientIP() + method := c.Request.Method + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + ua := c.Request.UserAgent() + + var userID interface{} + if uidAny, ok := c.Get("user_id"); ok { + if v, ok := uidAny.(int64); ok { + userID = v + } + } + + _, _ = s.auth.DB.Exec( + `INSERT INTO access_logs (user_id, ip, method, path, status, duration_ms, user_agent) VALUES (?,?,?,?,?,?,?)`, + userID, ip, method, path, status, duration.Milliseconds(), ua, + ) + } +} + +// IPBanEnforce blocks banned IPs (unless whitelisted). +func (s *Server) IPBanEnforce() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + var whitelisted, permanent int + var until sql.NullTime + err := s.auth.DB.QueryRow(`SELECT whitelisted, permanent, until FROM ip_bans WHERE ip = ?`, ip).Scan(&whitelisted, &permanent, &until) + if err != nil { + if err == sql.ErrNoRows { + c.Next() + return + } + s.logError(c, "ip_ban_lookup_failed: "+err.Error(), "") + c.Next() + return + } + + if whitelisted == 1 { + c.Next() + return + } + + banned := false + if permanent == 1 { + banned = true + } else if until.Valid && until.Time.After(time.Now()) { + banned = true + } + + if banned { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + c.Next() + } +} + +// logError stores error logs (best-effort). +func (s *Server) logError(c *gin.Context, message, stack string) { + var userID interface{} + if uidAny, ok := c.Get("user_id"); ok { + if v, ok := uidAny.(int64); ok { + userID = v + } + } + ip := c.ClientIP() + path := c.Request.URL.Path + _, _ = s.auth.DB.Exec(`INSERT INTO error_logs (user_id, ip, path, message, stack) VALUES (?,?,?,?,?)`, userID, ip, path, message, stack) +} diff --git a/internal/server/server.go b/internal/server/server.go index 85726a6..0410871 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "strings" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/sessions" @@ -43,8 +44,11 @@ func New(cfg *config.Config) *Server { auth: authSvc, } - // Global middlewares: session user + template setup + // Global middlewares s.router.Use(s.SessionUser()) + // Enforce IP bans/whitelists and log access for every request + s.router.Use(s.IPBanEnforce()) + s.router.Use(s.AccessLogger()) s.setupRoutes() s.setupStaticFiles() @@ -66,6 +70,9 @@ func (s *Server) Start() error { } } + // Start background cleanup for access logs older than 7 days (daily) + go s.startAccessLogCleanup() + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) fmt.Printf("Starting Gobsidian server on %s\n", addr) fmt.Printf("Notes directory: %s\n", s.config.NotesDir) @@ -119,6 +126,9 @@ func (s *Server) setupRoutes() { editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler) editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler) editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler) + // Security settings (IP ban thresholds/duration/permanent) + editor.GET("/settings/security", h.GetSecuritySettingsHandler) + editor.POST("/settings/security", h.PostSecuritySettingsHandler) // Profile editor.GET("/profile", h.ProfilePage) @@ -136,6 +146,14 @@ func (s *Server) setupRoutes() { // Admin CRUD API under /editor/admin admin := editor.Group("/admin", s.RequireAdmin()) { + // Logs page + admin.GET("/logs", h.AdminLogsPage) + // Manual clear old access logs (older than 7 days) + admin.POST("/logs/clear_access", h.AdminClearAccessLogs) + // Security: IP ban/whitelist actions + admin.POST("/ip/ban", h.AdminBanIP) + admin.POST("/ip/unban", h.AdminUnbanIP) + admin.POST("/ip/whitelist", h.AdminWhitelistIP) admin.POST("/users", h.AdminCreateUser) admin.DELETE("/users/:id", h.AdminDeleteUser) admin.POST("/users/:id/active", h.AdminSetUserActive) @@ -232,3 +250,13 @@ func (s *Server) setupTemplates() { fmt.Printf("DEBUG: Templates loaded successfully\n") } + +// startAccessLogCleanup deletes access logs older than 7 days once at startup and then daily. +func (s *Server) startAccessLogCleanup() { + // initial cleanup + _, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`) + ticker := time.NewTicker(24 * time.Hour) + for range ticker.C { + _, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`) + } +} diff --git a/web/templates/admin_logs.html b/web/templates/admin_logs.html new file mode 100644 index 0000000..0e1a85b --- /dev/null +++ b/web/templates/admin_logs.html @@ -0,0 +1,312 @@ +{{define "admin_logs"}} + {{template "base" .}} +{{end}} + +{{define "admin_logs_content"}} +
+
+
+

Admin Logs

+

Recent access, errors, failed logins, and IP bans

+
+ Back to Admin +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+ + +
+
+
Manual Ban/Whitelist
+
+ + + + + + +
+
+
+
+
Clear Access Logs
+
Delete entries older than N days (0 = delete all)
+
+
+ + +
+
+
+ + +
+ +
+ +
+ +
+
+

Access Logs

+
+ + + + + + + + + + + + + + + {{range .AccessLogs}} + + + + + + + + + + + {{else}} + + {{end}} + +
TimeIPMethodPathStatusDuration(ms)UserUA
{{formatTime .CreatedAt}}{{.IP}}{{.Method}}{{.Path}}{{.Status}}{{.Duration}}{{if .Username.Valid}}{{.Username.String}}{{else}}-{{end}}{{.UserAgent}}
No access logs
+
+
+
+ + + + + + + + + +
+
+{{end}} + +{{define "admin_logs_scripts"}} + +{{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 4f52312..489d14f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -259,6 +259,7 @@
+ {{if not .NoSidebar}} + {{end}}
@@ -357,6 +359,8 @@ {{template "settings_content" .}} {{else if eq .Page "admin"}} {{template "admin_content" .}} + {{else if eq .Page "admin_logs"}} + {{template "admin_logs_content" .}} {{else if eq .Page "profile"}} {{template "profile_content" .}} {{else if eq .Page "error"}} @@ -726,6 +730,8 @@ {{template "settings_scripts" .}} {{else if eq .Page "admin"}} {{template "admin_scripts" .}} + {{else if eq .Page "admin_logs"}} + {{template "admin_logs_scripts" .}} {{else if eq .Page "profile"}} {{template "profile_scripts" .}} {{else if eq .Page "error"}} diff --git a/web/templates/settings.html b/web/templates/settings.html index e05e40b..5ce01e1 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -12,6 +12,22 @@
+ +
+
+
+

+ Admin Tools +

+

Access logs and security controls

+
+ +
+

@@ -170,6 +186,58 @@

+ + +
+

+ Security (IP Ban & Thresholds) +

+

Configure failed login thresholds, window, and automatic ban behavior

+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +

If enabled, IPs exceeding thresholds are permanently banned instead of temporary bans.

+
+ +
+ +
+
+
{{end}} @@ -213,6 +281,18 @@ document.getElementById('show_files_in_folder').checked = !!data.show_files_in_folder; }) .catch(error => console.error('Error loading file extensions settings:', error)); + + // Load security settings + fetch('/editor/settings/security') + .then(response => response.json()) + .then(data => { + document.getElementById('pwd_failures_threshold').value = data.pwd_failures_threshold ?? ''; + document.getElementById('mfa_failures_threshold').value = data.mfa_failures_threshold ?? ''; + document.getElementById('failures_window_minutes').value = data.failures_window_minutes ?? ''; + document.getElementById('auto_ban_duration_hours').value = data.auto_ban_duration_hours ?? ''; + document.getElementById('auto_ban_permanent').checked = !!data.auto_ban_permanent; + }) + .catch(error => console.error('Error loading security settings:', error)); } // Toggle storage mode options @@ -232,6 +312,34 @@ } }); + // Security settings form + document.getElementById('security-settings-form').addEventListener('submit', function(e) { + e.preventDefault(); + + const formData = new FormData(this); + // Normalize checkbox to boolean string + formData.set('auto_ban_permanent', document.getElementById('auto_ban_permanent').checked ? 'true' : 'false'); + + const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : ''; + + fetch('/editor/settings/security', { + method: 'POST', + headers: csrf ? { 'X-CSRF-Token': csrf } : {}, + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification('Security settings saved successfully', 'success'); + } else { + throw new Error(data.error || 'Failed to save settings'); + } + }) + .catch(error => { + showNotification('Error: ' + error.message, 'error'); + }); + }); + // Image storage form document.getElementById('image-storage-form').addEventListener('submit', function(e) { e.preventDefault();