Files

107 lines
3.0 KiB
Go
Raw Permalink Normal View History

2026-05-24 07:18:54 +00:00
package internals
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// authFileLog writes structured JSON-lines to a file; nil = file logging off.
// Console logging always fires regardless of this setting.
var authFileLog *log.Logger
// authLogEntry is the structured format for each auth event.
// One JSON object per line — compatible with CrowdSec, fail2ban, jq.
type authLogEntry struct {
Time string `json:"time"` // RFC3339 UTC
RemoteIP string `json:"remote_ip"` // real client IP (proxy-aware)
Username string `json:"username"`
Success bool `json:"success"`
Message string `json:"message"` // login_success | invalid_credentials | csrf_invalid | invalid_input
}
// initAuthLogger opens (or creates) the log file.
// path "off" disables file logging; console output is always on.
func initAuthLogger(path string) {
if strings.EqualFold(path, "off") {
fmt.Println("auth log: disabled (console only)")
return
}
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "auth log: cannot create dir %q: %v\n", dir, err)
return
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
if err != nil {
fmt.Fprintf(os.Stderr, "auth log: cannot open %q: %v\n", path, err)
return
}
// log.New with empty flags → raw lines, no timestamp prefix (timestamp is in JSON)
authFileLog = log.New(f, "", 0)
fmt.Printf("auth log: %s\n", path)
}
// logAuthAttempt records one auth event.
// Always prints to stdout; also writes to file if enabled.
func logAuthAttempt(r *http.Request, username string, success bool, message string) {
entry := authLogEntry{
Time: time.Now().UTC().Format(time.RFC3339),
RemoteIP: realIP(r),
Username: username,
Success: success,
Message: message,
}
b, _ := json.Marshal(entry)
line := string(b)
// Console — always visible
fmt.Println(line)
// File — if enabled
if authFileLog != nil {
authFileLog.Println(line) // log.Logger serialises concurrent writes
}
}
// realIP returns the originating client IP, respecting common reverse-proxy
// headers in priority order: Cloudflare → X-Forwarded-For → X-Real-IP → RemoteAddr.
func realIP(r *http.Request) string {
// Cloudflare sets CF-Connecting-IP to the unmodified client IP.
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" && net.ParseIP(ip) != nil {
return ip
}
// X-Forwarded-For may be a comma-separated list; the leftmost entry is the
// originating client (rightmost entries are added by each successive proxy).
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.IndexByte(xff, ','); idx != -1 {
xff = xff[:idx]
}
xff = strings.TrimSpace(xff)
if net.ParseIP(xff) != nil {
return xff
}
}
// Nginx / Traefik single-value header.
if ip := r.Header.Get("X-Real-IP"); ip != "" && net.ParseIP(ip) != nil {
return ip
}
// Direct connection.
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}