Added readme to Manager. Add support for failed logging attempt log file, to enable fail2ban...
This commit is contained in:
83
manager/README.md
Normal file
83
manager/README.md
Normal 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)
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user