add loger, access log and bans/whitelist

This commit is contained in:
nahakubuilde
2025-08-26 07:46:01 +01:00
parent e21a0b5b10
commit 4cafd9848f
10 changed files with 1163 additions and 60 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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})
}

View File

@@ -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)
}

View File

@@ -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')`)
}
}