From 43852b673c0a05f97633b76b24ef0f2c2260870f Mon Sep 17 00:00:00 2001 From: Kalzu Rekku Date: Tue, 6 Jan 2026 15:05:59 +0200 Subject: [PATCH] Added readme to Manager. Add support for failed logging attempt log file, to enable fail2ban... --- manager/README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++ manager/logger.go | 25 +++++++++++--- manager/main.go | 22 ++++++++++++- 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 manager/README.md diff --git a/manager/README.md b/manager/README.md new file mode 100644 index 0000000..a612fa9 --- /dev/null +++ b/manager/README.md @@ -0,0 +1,83 @@ +# TwoStepAuth REST Client + +A secure, self-hosted web application for making REST API requests, protected by TOTP (Time-based One-Time Password) authentication and multi-layered encryption. + +## Features + +* **Two-Step Verification:** Mandatory TOTP (Google Authenticator, Authy, etc.). +* **Encrypted Storage:** User data is double-encrypted (AES-GCM) using both a Server Key and User-derived keys. +* **Automatic HTTPS:** Built-in Let's Encrypt (ACME) support. +* **Dynamic DNS:** Integrated `dy.fi` updater for home servers. +* **Security Logging:** `fail2ban`-ready logs to block brute-force attempts. +* **REST Client:** A clean UI to test GET/POST/PUT/DELETE requests with custom headers. + +## Quick Start + +### 1. Installation +```bash +go mod tidy +``` + +### 2. Configuration +The application uses environment variables for sensitive data. Create a `.env` file or export them: + +```bash +export SERVER_KEY="your-32-byte-base64-key" # Generated on first run if missing +export DYFI_DOMAIN="example.dy.fi" +export DYFI_USER="your-email@example.com" +export DYFI_PASS="dyfi-password" +export ACME_EMAIL="admin@example.com" +export LOG_FILE="/var/log/twostepauth.log" +``` + +### 3. Add a User +Run the application in CLI mode to generate a new user and their TOTP QR code: +```bash +go run . --add-user=myusername +``` +*Scan the QR code printed in the terminal with your authenticator app.* + +### 4. Run the Server + +**Production (Port 443 with Let's Encrypt):** +```bash +sudo go run . --port=443 --domain=example.dy.fi +``` + +**Development (Localhost with Self-Signed Certs):** +```bash +go run . --port=8080 +``` + +## Fail2Ban Integration + +The app logs `AUTH_FAILURE` events with the source IP. To enable automatic blocking: + +**Filter (`/etc/fail2ban/filter.d/twostepauth.conf`):** +```ini +[Definition] +failregex = AUTH_FAILURE: .* from IP +``` + +**Jail (`/etc/fail2ban/jail.d/twostepauth.local`):** +```ini +[twostepauth] +enabled = true +port = 80,443 +filter = twostepauth +logpath = /var/log/twostepauth.log +maxretry = 5 +``` + +## Security Architecture + +1. **Server Key:** Encrypts the entire user database file. +2. **User Key:** Derived from the User ID and Server Key via PBKDF2; encrypts individual user TOTP secrets. +3. **Session Security:** Session IDs are encrypted with the Server Key before being stored in a `Secure`, `HttpOnly`, `SameSite=Strict` cookie. +4. **TLS:** Minimum version TLS 1.2 enforced. + +## Requirements + +* Go 1.21+ +* Port 80/443 open (if using Let's Encrypt) +* Root privileges (if binding to ports < 1024 on Linux) \ No newline at end of file diff --git a/manager/logger.go b/manager/logger.go index 6c10d89..0e47f35 100644 --- a/manager/logger.go +++ b/manager/logger.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "log" "os" ) @@ -12,11 +13,27 @@ type Logger struct { errorLog *log.Logger } -func NewLogger() *Logger { +func NewLogger(logPath string) *Logger { + // Default to stdout/stderr + var infoOut io.Writer = os.Stdout + var errorOut io.Writer = os.Stderr + + if logPath != "" { + // Open log file in append mode, create if not exists + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Printf("Failed to open log file: %v\n", err) + } else { + // MultiWriter sends output to both the terminal and the file + infoOut = io.MultiWriter(os.Stdout, f) + errorOut = io.MultiWriter(os.Stderr, f) + } + } + return &Logger{ - infoLog: log.New(os.Stdout, "INFO ", log.Ldate|log.Ltime|log.Lshortfile), - warnLog: log.New(os.Stdout, "WARN ", log.Ldate|log.Ltime|log.Lshortfile), - errorLog: log.New(os.Stderr, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile), + infoLog: log.New(infoOut, "INFO ", log.Ldate|log.Ltime|log.Lshortfile), + warnLog: log.New(infoOut, "WARN ", log.Ldate|log.Ltime|log.Lshortfile), + errorLog: log.New(errorOut, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile), } } diff --git a/manager/main.go b/manager/main.go index 232d205..eb6337c 100644 --- a/manager/main.go +++ b/manager/main.go @@ -48,10 +48,11 @@ func main() { dyfiUser := flag.String("dyfi-user", os.Getenv("DYFI_USER"), "dy.fi username (email)") dyfiPass := flag.String("dyfi-pass", os.Getenv("DYFI_PASS"), "dy.fi password") email := flag.String("email", os.Getenv("ACME_EMAIL"), "Email for Let's Encrypt notifications") + logFile := flag.String("log", os.Getenv("LOG_FILE"), "Path to log file for fail2ban") flag.Parse() - logger = NewLogger() + logger = NewLogger(*logFile) // --- ENCRYPTION INITIALIZATION --- serverKey := os.Getenv("SERVER_KEY") @@ -180,6 +181,8 @@ func main() { userID := strings.TrimSpace(r.FormValue("userid")) user, err := store.GetUser(userID) if err != nil || user == nil { + // FAIL2BAN TRIGGER + logger.Warn("AUTH_FAILURE: User not found: %s from IP %s", userID, getIP(r)) tmpl.Execute(w, map[string]interface{}{"Step2": false, "Error": "User not found"}) return } @@ -219,10 +222,15 @@ func main() { return } + // Get the user from the store and the TOTP code from the form user, _ := store.GetUser(session.UserID) totpCode := strings.TrimSpace(r.FormValue("totp")) + // Validate the TOTP code if !totp.Validate(totpCode, user.TOTPSecret) { + // --- FAIL2BAN TRIGGER --- + logger.Warn("AUTH_FAILURE: Invalid TOTP for user %s from IP %s", session.UserID, getIP(r)) + tmpl.Execute(w, map[string]interface{}{"Step2": true, "Error": "Invalid TOTP code"}) return } @@ -230,6 +238,7 @@ func main() { sessions.Lock() delete(sessions.m, cookie.Value) + // Create a new long-lived authenticated session (1 hour) authSessionID := fmt.Sprintf("%d", time.Now().UnixNano()) sessions.m[authSessionID] = &Session{ UserID: session.UserID, @@ -249,6 +258,7 @@ func main() { MaxAge: 3600, }) + // Redirect to the main application http.Redirect(w, r, "/app", http.StatusSeeOther) }) @@ -371,6 +381,16 @@ func cleanupSessions() { } } +func getIP(r *http.Request) string { + // Check for X-Forwarded-For if you are behind a proxy (Nginx/Cloudflare) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.Split(xff, ",")[0] + } + // Otherwise use RemoteAddr (strip the port) + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + return ip +} + func makeHTTPRequest(method, url string, headers map[string]string, body string) map[string]interface{} { client := &http.Client{Timeout: 30 * time.Second}