Added readme to Manager. Add support for failed logging attempt log file, to enable fail2ban...

This commit is contained in:
Kalzu Rekku
2026-01-06 15:05:59 +02:00
parent f7056082f6
commit 43852b673c
3 changed files with 125 additions and 5 deletions

83
manager/README.md Normal file
View File

@@ -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 <HOST>
```
**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)

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
) )
@@ -12,11 +13,27 @@ type Logger struct {
errorLog *log.Logger 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{ return &Logger{
infoLog: log.New(os.Stdout, "INFO ", log.Ldate|log.Ltime|log.Lshortfile), infoLog: log.New(infoOut, "INFO ", log.Ldate|log.Ltime|log.Lshortfile),
warnLog: log.New(os.Stdout, "WARN ", log.Ldate|log.Ltime|log.Lshortfile), warnLog: log.New(infoOut, "WARN ", log.Ldate|log.Ltime|log.Lshortfile),
errorLog: log.New(os.Stderr, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile), errorLog: log.New(errorOut, "ERROR ", log.Ldate|log.Ltime|log.Lshortfile),
} }
} }

View File

@@ -48,10 +48,11 @@ func main() {
dyfiUser := flag.String("dyfi-user", os.Getenv("DYFI_USER"), "dy.fi username (email)") 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") 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") 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() flag.Parse()
logger = NewLogger() logger = NewLogger(*logFile)
// --- ENCRYPTION INITIALIZATION --- // --- ENCRYPTION INITIALIZATION ---
serverKey := os.Getenv("SERVER_KEY") serverKey := os.Getenv("SERVER_KEY")
@@ -180,6 +181,8 @@ func main() {
userID := strings.TrimSpace(r.FormValue("userid")) userID := strings.TrimSpace(r.FormValue("userid"))
user, err := store.GetUser(userID) user, err := store.GetUser(userID)
if err != nil || user == nil { 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"}) tmpl.Execute(w, map[string]interface{}{"Step2": false, "Error": "User not found"})
return return
} }
@@ -219,10 +222,15 @@ func main() {
return return
} }
// Get the user from the store and the TOTP code from the form
user, _ := store.GetUser(session.UserID) user, _ := store.GetUser(session.UserID)
totpCode := strings.TrimSpace(r.FormValue("totp")) totpCode := strings.TrimSpace(r.FormValue("totp"))
// Validate the TOTP code
if !totp.Validate(totpCode, user.TOTPSecret) { 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"}) tmpl.Execute(w, map[string]interface{}{"Step2": true, "Error": "Invalid TOTP code"})
return return
} }
@@ -230,6 +238,7 @@ func main() {
sessions.Lock() sessions.Lock()
delete(sessions.m, cookie.Value) delete(sessions.m, cookie.Value)
// Create a new long-lived authenticated session (1 hour)
authSessionID := fmt.Sprintf("%d", time.Now().UnixNano()) authSessionID := fmt.Sprintf("%d", time.Now().UnixNano())
sessions.m[authSessionID] = &Session{ sessions.m[authSessionID] = &Session{
UserID: session.UserID, UserID: session.UserID,
@@ -249,6 +258,7 @@ func main() {
MaxAge: 3600, MaxAge: 3600,
}) })
// Redirect to the main application
http.Redirect(w, r, "/app", http.StatusSeeOther) 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{} { func makeHTTPRequest(method, url string, headers map[string]string, body string) map[string]interface{} {
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}