107 lines
3.0 KiB
Go
107 lines
3.0 KiB
Go
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
|
|
}
|