Additional security and hardening.

This commit is contained in:
2025-03-31 15:14:16 +01:00
parent c3a7480c65
commit 7eefb9ced0
13 changed files with 274 additions and 47 deletions

54
handlers/admin/audit.go Normal file
View File

@@ -0,0 +1,54 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"synlotto-website/helpers"
"synlotto-website/middleware"
)
type AdminLogEntry struct {
AccessedAt string
UserID int
Path string
IP string
UserAgent string
}
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
context := helpers.TemplateContext(w, r)
rows, err := db.Query(`
SELECT accessed_at, user_id, path, ip, user_agent
FROM admin_access_log
ORDER BY accessed_at DESC
LIMIT 100
`)
if err != nil {
log.Println("⚠️ Failed to load admin access logs:", err)
http.Error(w, "Error loading logs", http.StatusInternalServerError)
return
}
defer rows.Close()
var logs []AdminLogEntry
for rows.Next() {
var entry AdminLogEntry
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
log.Println("⚠️ Scan failed:", err)
continue
}
logs = append(logs, entry)
}
context["AuditLogs"] = logs
tmpl := template.Must(template.New("").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/access_log.html",
))
_ = tmpl.ExecuteTemplate(w, "layout", context)
})
}

View File

@@ -3,9 +3,11 @@ package handlers
import ( import (
"database/sql" "database/sql"
"html/template" "html/template"
"log"
"net/http" "net/http"
helpers "synlotto-website/helpers" helpers "synlotto-website/helpers"
"synlotto-website/models"
) )
func NewDrawHandler(db *sql.DB) http.HandlerFunc { func NewDrawHandler(db *sql.DB) http.HandlerFunc {
@@ -70,3 +72,41 @@ func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
} }
}) })
} }
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
context := helpers.TemplateContext(w, r)
draws := []models.DrawSummary{}
rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
FROM results_thunderball r
ORDER BY r.draw_date DESC
`)
if err != nil {
http.Error(w, "Failed to query draws", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var d models.DrawSummary
var prizeFlag int
if err := rows.Scan(&d.Id, &d.GameType, &d.DrawDate, &d.BallSet, &d.Machine, &prizeFlag); err != nil {
log.Println("⚠️ Draw scan failed:", err)
continue
}
d.PrizeSet = prizeFlag > 0
draws = append(draws, d)
}
context["Draws"] = draws
tmpl := template.Must(template.New("draw_list").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/list.html",
))
tmpl.ExecuteTemplate(w, "layout", context)
})
}

View File

@@ -107,41 +107,3 @@ func Submit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
return helpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
context := helpers.TemplateContext(w, r)
draws := []models.DrawSummary{}
rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
FROM results_thunderball r
ORDER BY r.draw_date DESC
`)
if err != nil {
http.Error(w, "Failed to query draws", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var d models.DrawSummary
var prizeFlag int
if err := rows.Scan(&d.Id, &d.GameType, &d.DrawDate, &d.BallSet, &d.Machine, &prizeFlag); err != nil {
log.Println("⚠️ Draw scan failed:", err)
continue
}
d.PrizeSet = prizeFlag > 0
draws = append(draws, d)
}
context["Draws"] = draws
tmpl := template.Must(template.New("draw_list").Funcs(helpers.TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/admin/draws/list.html",
))
tmpl.ExecuteTemplate(w, "layout", context)
})
}

16
helpers/database.go Normal file
View File

@@ -0,0 +1,16 @@
package helpers
import (
"database/sql"
"log"
)
func IsAdmin(db *sql.DB, userID int) bool {
var isAdmin bool
err := db.QueryRow(`SELECT is_admin FROM users WHERE id = ?`, userID).Scan(&isAdmin)
if err != nil {
log.Printf("⚠️ Failed to check is_admin for user %d: %v", userID, err)
return false
}
return isAdmin
}

17
helpers/pages.go Normal file
View File

@@ -0,0 +1,17 @@
package helpers
// ToDo should be a handler?
import (
"html/template"
"net/http"
)
func Render403(w http.ResponseWriter, r *http.Request) {
context := TemplateContext(w, r)
tmpl := template.Must(template.New("").Funcs(TemplateFuncs()).ParseFiles(
"templates/layout.html",
"templates/error/403.html",
))
tmpl.ExecuteTemplate(w, "layout", context)
}

24
main.go
View File

@@ -18,6 +18,8 @@ func main() {
db := storage.InitDB("synlotto.db") db := storage.InitDB("synlotto.db")
models.SetDB(db) models.SetDB(db)
var isProduction = false
csrfMiddleware := csrf.Protect( csrfMiddleware := csrf.Protect(
[]byte("abcdefghijklmnopqrstuvwx12345678"), // TodO: Make Global []byte("abcdefghijklmnopqrstuvwx12345678"), // TodO: Make Global
csrf.Secure(true), csrf.Secure(true),
@@ -33,22 +35,28 @@ func main() {
mux.HandleFunc("/", handlers.Home(db)) mux.HandleFunc("/", handlers.Home(db))
wrapped := helpers.RateLimit(csrfMiddleware(mux))
wrapped = middleware.EnforceHTTPS(wrapped, isProduction)
wrapped = middleware.SecureHeaders(wrapped)
wrapped = middleware.Recover(wrapped)
log.Println("🌐 Running on http://localhost:8080") log.Println("🌐 Running on http://localhost:8080")
http.ListenAndServe(":8080", helpers.RateLimit(csrfMiddleware(mux))) http.ListenAndServe(":8080", wrapped)
} }
func setupAdminRoutes(mux *http.ServeMux, db *sql.DB) { func setupAdminRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/admin/dashboard", admin.AdminDashboardHandler(db)) mux.HandleFunc("/admin/access", middleware.AdminOnly(db, admin.AdminAccessLogHandler(db)))
mux.HandleFunc("/admin/triggers", admin.AdminTriggersHandler(db)) mux.HandleFunc("/admin/dashboard", middleware.AdminOnly(db, admin.AdminDashboardHandler(db)))
mux.HandleFunc("/admin/triggers", middleware.AdminOnly(db, admin.AdminTriggersHandler(db)))
// Draw management // Draw management
mux.HandleFunc("/admin/draws/new", admin.NewDrawHandler(db)) mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.NewDrawHandler(db)))
mux.HandleFunc("/admin/draws/modify", admin.ModifyDrawHandler(db)) mux.HandleFunc("/admin/draws/modify", middleware.AdminOnly(db, admin.ModifyDrawHandler(db)))
mux.HandleFunc("/admin/draws/delete", admin.DeleteDrawHandler(db)) mux.HandleFunc("/admin/draws/delete", middleware.AdminOnly(db, admin.DeleteDrawHandler(db)))
// Prize management // Prize management
mux.HandleFunc("/admin/draws/prizes/add", admin.AddPrizesHandler(db)) mux.HandleFunc("/admin/draws/prizes/add", middleware.AdminOnly(db, admin.AddPrizesHandler(db)))
mux.HandleFunc("/admin/draws/prizes/modify", admin.ModifyPrizesHandler(db)) mux.HandleFunc("/admin/draws/prizes/modify", middleware.AdminOnly(db, admin.ModifyPrizesHandler(db)))
} }
func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) { func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {

38
middleware/admin.go Normal file
View File

@@ -0,0 +1,38 @@
package middleware
import (
"database/sql"
"log"
"net/http"
"synlotto-website/helpers"
)
// Wraps an existing handler but checks is_admin before executing
func AdminOnly(db *sql.DB, next http.HandlerFunc) http.HandlerFunc {
return Auth(true)(func(w http.ResponseWriter, r *http.Request) {
userID, ok := helpers.GetCurrentUserID(r)
if !ok || !helpers.IsAdmin(db, userID) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
ip := r.RemoteAddr
ua := r.UserAgent()
path := r.URL.Path
// Store log entry in DB
_, err := db.Exec(`
INSERT INTO admin_access_log (user_id, path, ip, user_agent)
VALUES (?, ?, ?, ?)`,
userID, path, ip, ua,
)
if err != nil {
log.Printf("⚠️ Failed to log admin access: %v", err)
}
// Optional: still log to console
log.Printf("🛡️ Admin access: user_id=%d IP=%s Path=%s", userID, ip, path)
next(w, r)
})
}

20
middleware/recover.go Normal file
View File

@@ -0,0 +1,20 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

24
middleware/security.go Normal file
View File

@@ -0,0 +1,24 @@
package middleware
import "net/http"
// Redirects all HTTP to HTTPS (only in production)
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
return
}
next.ServeHTTP(w, r)
})
}
func SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
next.ServeHTTP(w, r)
})
}

View File

@@ -137,7 +137,8 @@ func InitDB(filepath string) *sql.DB {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL password_hash TEXT NOT NULL,
is_admin BOOLEAN
);` );`
if _, err := db.Exec(createUsersTable); err != nil { if _, err := db.Exec(createUsersTable); err != nil {
@@ -170,5 +171,18 @@ func InitDB(filepath string) *sql.DB {
log.Fatal("❌ Failed to create ticket matching log table:", err) log.Fatal("❌ Failed to create ticket matching log table:", err)
} }
createAdminAccessLogTable := `
CREATE TABLE IF NOT EXISTS admin_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
path TEXT,
ip TEXT,
user_agent TEXT
);`
if _, err := db.Exec(createAdminAccessLogTable); err != nil {
log.Fatal("❌ Failed to create admin access log table:", err)
}
return db return db
} }

View File

@@ -0,0 +1,26 @@
{{ define "content" }}
<h2>Admin Access Log</h2>
<table class="table-auto w-full text-sm mt-4">
<thead>
<tr class="bg-gray-200">
<th class="px-2 py-1 text-left">Time</th>
<th class="px-2 py-1">User ID</th>
<th class="px-2 py-1">Path</th>
<th class="px-2 py-1">IP</th>
<th class="px-2 py-1">User Agent</th>
</tr>
</thead>
<tbody>
{{ range .AuditLogs }}
<tr class="border-b">
<td class="px-2 py-1">{{ .AccessedAt }}</td>
<td class="px-2 py-1 text-center">{{ .UserID }}</td>
<td class="px-2 py-1">{{ .Path }}</td>
<td class="px-2 py-1">{{ .IP }}</td>
<td class="px-2 py-1 text-xs text-gray-600">{{ .UserAgent }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}

5
templates/error/403.html Normal file
View File

@@ -0,0 +1,5 @@
{{ define "content" }}
<h2 class="text-red-600 text-xl font-bold">🚫 Forbidden</h2>
<p class="mt-2">You do not have permission to access this page.</p>
<a href="/" class="text-blue-500 underline mt-4 inline-block">Return to Home</a>
{{ end }}

3
templates/error/404.html Normal file
View File

@@ -0,0 +1,3 @@
{{ define "content" }}
<h2>Not Found</h2> <p>The page doesn't exist.</p>
{{ end }}