Improved input service. New Manager web app. Directory and small readme for output service.

This commit is contained in:
Kalzu Rekku
2026-01-06 14:27:26 +02:00
parent ec9fec5ce3
commit f7056082f6
11 changed files with 1695 additions and 293 deletions

67
manager/cert.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
)
func CheckAndGenerateCerts() (string, string, error) {
certFile := "cert.pem"
keyFile := "key.pem"
// If files already exist, just use them
if _, err := os.Stat(certFile); err == nil {
return certFile, keyFile, nil
}
logger.Info("Generating self-signed TLS certificates...")
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit)
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"TwoStepAuth Dev"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return "", "", err
}
certOut, _ := os.Create(certFile)
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
certOut.Close()
keyOut, _ := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
privBytes, _ := x509.MarshalECPrivateKey(priv)
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
keyOut.Close()
return certFile, keyFile, nil
}

109
manager/crypto.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"golang.org/x/crypto/pbkdf2"
)
type Crypto struct {
serverKey []byte
}
func NewCrypto(serverKeyStr string) (*Crypto, error) {
// Decode the base64 server key
serverKey, err := base64.StdEncoding.DecodeString(serverKeyStr)
if err != nil {
return nil, err
}
if len(serverKey) != 32 {
return nil, errors.New("invalid server key length")
}
logger.Info("Crypto initialized with server key")
return &Crypto{serverKey: serverKey}, nil
}
func GenerateServerKey() (string, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
func (c *Crypto) deriveUserKey(userID string) []byte {
// Derive a 32-byte key from user ID using PBKDF2
// Using server key as salt for additional security
return pbkdf2.Key([]byte(userID), c.serverKey, 100000, 32, sha256.New)
}
func (c *Crypto) encrypt(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, data, nil)
return ciphertext, nil
}
func (c *Crypto) decrypt(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func (c *Crypto) EncryptWithServerKey(data []byte) ([]byte, error) {
return c.encrypt(data, c.serverKey)
}
func (c *Crypto) DecryptWithServerKey(data []byte) ([]byte, error) {
return c.decrypt(data, c.serverKey)
}
func (c *Crypto) EncryptWithUserKey(data []byte, userID string) ([]byte, error) {
userKey := c.deriveUserKey(userID)
return c.encrypt(data, userKey)
}
func (c *Crypto) DecryptWithUserKey(data []byte, userID string) ([]byte, error) {
userKey := c.deriveUserKey(userID)
return c.decrypt(data, userKey)
}

42
manager/dyfi.go Normal file
View File

@@ -0,0 +1,42 @@
package main
import (
"fmt"
"net/http"
"time"
)
func startDyfiUpdater(hostname, username, password string) {
if hostname == "" || username == "" || password == "" {
return
}
logger.Info("Starting dy.fi updater for %s", hostname)
update := func() {
url := fmt.Sprintf("https://www.dy.fi/nic/update?hostname=%s", hostname)
req, _ := http.NewRequest("GET", url, nil)
req.SetBasicAuth(username, password)
req.Header.Set("User-Agent", "Go-TwoStepAuth-Client/1.0")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
logger.Error("dy.fi update failed: %v", err)
return
}
defer resp.Body.Close()
logger.Info("dy.fi update status: %s", resp.Status)
}
// Update immediately on start
update()
// Update every 7 days (dy.fi requires update at least every 30 days)
go func() {
ticker := time.NewTicker(7 * 24 * time.Hour)
for range ticker.C {
update()
}
}()
}

15
manager/go.mod Normal file
View File

@@ -0,0 +1,15 @@
module manager
go 1.25.0
require (
github.com/pquerna/otp v1.5.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.46.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

17
manager/gr.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"fmt"
"github.com/skip2/go-qrcode"
)
func PrintQRCode(content string) {
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
logger.Error("Failed to generate QR code: %v", err)
return
}
// Generate QR code as string (ASCII art)
fmt.Println(qr.ToSmallString(false))
}

33
manager/logger.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"fmt"
"log"
"os"
)
type Logger struct {
infoLog *log.Logger
warnLog *log.Logger
errorLog *log.Logger
}
func NewLogger() *Logger {
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),
}
}
func (l *Logger) Info(format string, v ...interface{}) {
l.infoLog.Output(2, fmt.Sprintf(format, v...))
}
func (l *Logger) Warn(format string, v ...interface{}) {
l.warnLog.Output(2, fmt.Sprintf(format, v...))
}
func (l *Logger) Error(format string, v ...interface{}) {
l.errorLog.Output(2, fmt.Sprintf(format, v...))
}

424
manager/main.go Normal file
View File

@@ -0,0 +1,424 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/acme/autocert"
)
var (
store *UserStore
sessions = struct {
sync.RWMutex
m map[string]*Session
}{m: make(map[string]*Session)}
logger *Logger
)
type Session struct {
UserID string
ExpiresAt time.Time
}
func main() {
// --- FLAGS ---
addUser := flag.String("add-user", "", "Add a new user (provide user ID)")
port := flag.String("port", os.Getenv("MANAGER_PORT"), "Port to run the server on (use 443 for Let's Encrypt)")
domain := flag.String("domain", os.Getenv("DYFI_DOMAIN"), "Your dy.fi domain (e.g. example.dy.fi)")
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")
flag.Parse()
logger = NewLogger()
// --- ENCRYPTION INITIALIZATION ---
serverKey := os.Getenv("SERVER_KEY")
if serverKey == "" {
logger.Warn("SERVER_KEY not set, generating new key")
var err error
serverKey, err = GenerateServerKey()
if err != nil {
logger.Error("Failed to generate server key: %v", err)
log.Fatal(err)
}
fmt.Printf("\n⚠ IMPORTANT: Save this SERVER_KEY to your environment:\n")
fmt.Printf("export SERVER_KEY=%s\n\n", serverKey)
}
crypto, err := NewCrypto(serverKey)
if err != nil {
logger.Error("Failed to initialize crypto: %v", err)
log.Fatal(err)
}
store = NewUserStore("users_data", crypto)
// --- BACKGROUND TASKS ---
// Reload user store from disk periodically
go func() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
if err := store.Reload(); err != nil {
logger.Error("Failed to reload user store: %v", err)
}
}
}()
// Cleanup expired sessions
go func() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
cleanupSessions()
}
}()
// dy.fi Dynamic DNS Updater
if *domain != "" && *dyfiUser != "" {
startDyfiUpdater(*domain, *dyfiUser, *dyfiPass)
}
// --- CLI COMMANDS ---
if *addUser != "" {
handleNewUser(*addUser)
return
}
// --- TEMPLATE LOADING ---
tmpl, err := LoadTemplate()
if err != nil {
log.Fatal(err)
}
appTmpl, err := LoadAppTemplate()
if err != nil {
log.Fatal(err)
}
// --- ROUTES ---
// Routes must be defined BEFORE the server starts
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if session := getValidSession(r, crypto); session != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
tmpl.Execute(w, map[string]interface{}{"Step2": false})
})
http.HandleFunc("/app", func(w http.ResponseWriter, r *http.Request) {
session := getValidSession(r, crypto)
if session == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
appTmpl.Execute(w, map[string]interface{}{"UserID": session.UserID})
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("auth_session")
if err == nil {
sessions.Lock()
delete(sessions.m, cookie.Value)
sessions.Unlock()
}
http.SetCookie(w, &http.Cookie{
Name: "auth_session",
Value: "",
Path: "/",
MaxAge: -1,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
})
http.HandleFunc("/api/request", func(w http.ResponseWriter, r *http.Request) {
session := getValidSession(r, crypto)
if session == nil {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"})
return
}
var req struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
result := makeHTTPRequest(req.Method, req.URL, req.Headers, req.Body)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
http.HandleFunc("/verify-user", func(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.FormValue("userid"))
user, err := store.GetUser(userID)
if err != nil || user == nil {
tmpl.Execute(w, map[string]interface{}{"Step2": false, "Error": "User not found"})
return
}
sessionID := fmt.Sprintf("%d", time.Now().UnixNano())
sessions.Lock()
sessions.m[sessionID] = &Session{
UserID: userID,
ExpiresAt: time.Now().Add(5 * time.Minute),
}
sessions.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "temp_session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
tmpl.Execute(w, map[string]interface{}{"Step2": true})
})
http.HandleFunc("/verify-totp", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("temp_session")
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
sessions.RLock()
session, ok := sessions.m[cookie.Value]
sessions.RUnlock()
if !ok || time.Now().After(session.ExpiresAt) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
user, _ := store.GetUser(session.UserID)
totpCode := strings.TrimSpace(r.FormValue("totp"))
if !totp.Validate(totpCode, user.TOTPSecret) {
tmpl.Execute(w, map[string]interface{}{"Step2": true, "Error": "Invalid TOTP code"})
return
}
sessions.Lock()
delete(sessions.m, cookie.Value)
authSessionID := fmt.Sprintf("%d", time.Now().UnixNano())
sessions.m[authSessionID] = &Session{
UserID: session.UserID,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
sessions.Unlock()
encryptedSession, _ := crypto.EncryptWithServerKey([]byte(authSessionID))
http.SetCookie(w, &http.Cookie{
Name: "auth_session",
Value: base64.StdEncoding.EncodeToString(encryptedSession),
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 3600,
})
http.Redirect(w, r, "/app", http.StatusSeeOther)
})
// --- SERVER STARTUP ---
if *domain != "" {
// Let's Encrypt / ACME Setup
certManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(*domain),
Cache: autocert.DirCache("certs_cache"),
Email: *email,
}
// Let's Encrypt requires port 80 for the HTTP-01 challenge
go func() {
logger.Info("Starting HTTP-01 challenge listener on port 80")
// This handler automatically redirects HTTP to HTTPS while solving challenges
log.Fatal(http.ListenAndServe(":80", certManager.HTTPHandler(nil)))
}()
server := &http.Server{
Addr: ":" + *port,
TLSConfig: certManager.TLSConfig(),
}
logger.Info("Secure Server starting with Let's Encrypt on https://%s", *domain)
log.Fatal(server.ListenAndServeTLS("", "")) // Certs provided by autocert
} else {
// Fallback to Self-Signed Certs
certFile, keyFile, err := setupCerts()
if err != nil {
logger.Error("TLS Setup Error: %v", err)
log.Fatal(err)
}
server := &http.Server{
Addr: ":" + *port,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
logger.Info("Secure Server starting with self-signed certs on https://localhost:%s", *port)
log.Fatal(server.ListenAndServeTLS(certFile, keyFile))
}
}
// setupCerts creates self-signed certs if they don't exist
func setupCerts() (string, string, error) {
certFile, keyFile := "cert.pem", "key.pem"
if _, err := os.Stat(certFile); err == nil {
return certFile, keyFile, nil
}
logger.Info("Generating self-signed certificates for HTTPS...")
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{Organization: []string{"TwoStepAuth Dev"}},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
certOut, _ := os.Create(certFile)
pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
certOut.Close()
keyOut, _ := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
privBytes, _ := x509.MarshalECPrivateKey(priv)
pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
keyOut.Close()
return certFile, keyFile, nil
}
func handleNewUser(userID string) {
secret, _ := generateSecret()
store.AddUser(userID, secret)
otpauthURL := fmt.Sprintf("otpauth://totp/TwoStepAuth:%s?secret=%s&issuer=TwoStepAuth", userID, secret)
fmt.Printf("\nUser: %s\nSecret: %s\n", userID, secret)
PrintQRCode(otpauthURL)
}
func getValidSession(r *http.Request, crypto *Crypto) *Session {
cookie, err := r.Cookie("auth_session")
if err != nil {
return nil
}
enc, _ := base64.StdEncoding.DecodeString(cookie.Value)
sid, err := crypto.DecryptWithServerKey(enc)
if err != nil {
return nil
}
sessions.RLock()
session, ok := sessions.m[string(sid)]
sessions.RUnlock()
if !ok || time.Now().After(session.ExpiresAt) {
return nil
}
return session
}
func cleanupSessions() {
sessions.Lock()
defer sessions.Unlock()
now := time.Now()
for id, s := range sessions.m {
if now.After(s.ExpiresAt) {
delete(sessions.m, id)
}
}
}
func makeHTTPRequest(method, url string, headers map[string]string, body string) map[string]interface{} {
client := &http.Client{Timeout: 30 * time.Second}
var reqBody io.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return map[string]interface{}{
"error": err.Error(),
}
}
for key, value := range headers {
req.Header.Set(key, value)
}
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start).Milliseconds()
if err != nil {
return map[string]interface{}{
"error": err.Error(),
"duration": duration,
}
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return map[string]interface{}{
"error": err.Error(),
"duration": duration,
}
}
respHeaders := make(map[string]string)
for key, values := range resp.Header {
respHeaders[key] = strings.Join(values, ", ")
}
return map[string]interface{}{
"status": resp.StatusCode,
"headers": respHeaders,
"body": string(bodyBytes),
"duration": duration,
}
}

215
manager/store.go Normal file
View File

@@ -0,0 +1,215 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
)
type User struct {
ID string `json:"id"`
TOTPSecret string `json:"totp_secret"`
}
type UserStore struct {
mu sync.RWMutex
filePath string
crypto *Crypto
cache map[string]*encryptedUserEntry
}
type encryptedUserEntry struct {
UserIDHash string `json:"hash"`
Data []byte `json:"data"`
}
type encryptedStore struct {
Users []encryptedUserEntry `json:"users"`
}
func NewUserStore(dataDir string, crypto *Crypto) *UserStore {
// Create data directory if it doesn't exist
if err := os.MkdirAll(dataDir, 0700); err != nil {
logger.Error("Failed to create data directory: %v", err)
}
filePath := filepath.Join(dataDir, "users.enc")
logger.Info("Initialized user store at: %s", filePath)
store := &UserStore{
filePath: filePath,
crypto: crypto,
cache: make(map[string]*encryptedUserEntry),
}
store.loadCache()
return store
}
func (s *UserStore) hashUserID(userID string) string {
hash := sha256.Sum256([]byte(userID))
return hex.EncodeToString(hash[:])
}
func (s *UserStore) Reload() error {
s.mu.Lock()
defer s.mu.Unlock()
// Clear existing cache
s.cache = make(map[string]*encryptedUserEntry)
// Reload from disk
return s.loadCacheInternal()
}
func (s *UserStore) loadCache() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadCacheInternal()
}
func (s *UserStore) loadCacheInternal() error {
// Read encrypted store file
encryptedData, err := os.ReadFile(s.filePath)
if err != nil {
if os.IsNotExist(err) {
logger.Info("No existing user store found, starting fresh")
return nil
}
logger.Error("Failed to read store file: %v", err)
return err
}
// Decrypt with server key
decryptedData, err := s.crypto.DecryptWithServerKey(encryptedData)
if err != nil {
logger.Error("Failed to decrypt store: %v", err)
return err
}
var store encryptedStore
if err := json.Unmarshal(decryptedData, &store); err != nil {
logger.Error("Failed to unmarshal store: %v", err)
return err
}
// Load into cache
for i := range store.Users {
s.cache[store.Users[i].UserIDHash] = &store.Users[i]
}
logger.Info("Loaded %d encrypted user entries into cache", len(s.cache))
return nil
}
func (s *UserStore) save() error {
// Build store structure from cache
store := encryptedStore{
Users: make([]encryptedUserEntry, 0, len(s.cache)),
}
for _, entry := range s.cache {
store.Users = append(store.Users, *entry)
}
// Marshal to JSON
storeData, err := json.Marshal(store)
if err != nil {
return err
}
// Encrypt with server key
encryptedData, err := s.crypto.EncryptWithServerKey(storeData)
if err != nil {
logger.Error("Failed to encrypt store: %v", err)
return err
}
// Write to file
logger.Info("Saving user store with %d entries", len(s.cache))
if err := os.WriteFile(s.filePath, encryptedData, 0600); err != nil {
logger.Error("Failed to write store file: %v", err)
return err
}
return nil
}
func (s *UserStore) GetUser(userID string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
userHash := s.hashUserID(userID)
entry, exists := s.cache[userHash]
if !exists {
logger.Warn("User not found in cache")
return nil, nil
}
// Decrypt with user key (derived from user ID)
userData, err := s.crypto.DecryptWithUserKey(entry.Data, userID)
if err != nil {
logger.Error("Failed to decrypt user data: %v", err)
return nil, err
}
var user User
if err := json.Unmarshal(userData, &user); err != nil {
logger.Error("Failed to unmarshal user data: %v", err)
return nil, err
}
logger.Info("Successfully loaded user: %s", user.ID)
return &user, nil
}
func (s *UserStore) AddUser(userID, totpSecret string) error {
s.mu.Lock()
defer s.mu.Unlock()
user := &User{
ID: userID,
TOTPSecret: totpSecret,
}
// Marshal user data
userData, err := json.Marshal(user)
if err != nil {
return err
}
// Encrypt with user key (derived from user ID)
userEncrypted, err := s.crypto.EncryptWithUserKey(userData, userID)
if err != nil {
logger.Error("Failed to encrypt with user key: %v", err)
return err
}
// Add to cache
userHash := s.hashUserID(userID)
s.cache[userHash] = &encryptedUserEntry{
UserIDHash: userHash,
Data: userEncrypted,
}
// Save entire store (encrypted with server key)
if err := s.save(); err != nil {
return err
}
logger.Info("Successfully saved user: %s", userID)
return nil
}
func generateSecret() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base32.StdEncoding.EncodeToString(b), nil
}

459
manager/template.go Normal file
View File

@@ -0,0 +1,459 @@
package main
import (
"html/template"
"os"
)
func LoadTemplate() (*template.Template, error) {
// Try to load from file first
if _, err := os.Stat("template.html"); err == nil {
logger.Info("Loading template from template.html")
return template.ParseFiles("template.html")
}
// Fall back to embedded template
logger.Info("Using embedded template")
return template.New("page").Parse(embeddedTemplate)
}
func LoadAppTemplate() (*template.Template, error) {
// Try to load from file first
if _, err := os.Stat("app.html"); err == nil {
logger.Info("Loading app template from app.html")
return template.ParseFiles("app.html")
}
// Fall back to embedded template
logger.Info("Using embedded app template")
return template.New("app").Parse(embeddedAppTemplate)
}
const embeddedTemplate = `<!DOCTYPE html>
<html>
<head>
<title>Two-Step Authentication</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #b8c5d6;
}
.container {
text-align: center;
width: 100%;
max-width: 500px;
padding: 20px;
}
h1 {
font-size: 28px;
margin-bottom: 40px;
color: #4a9eff;
font-weight: 300;
letter-spacing: 1px;
}
.form-group {
display: flex;
gap: 15px;
align-items: center;
justify-content: center;
}
input {
flex: 1;
max-width: 300px;
padding: 18px 24px;
font-size: 18px;
border: 2px solid #2c3e50;
background: #1e2835;
color: #e0e6ed;
border-radius: 8px;
outline: none;
transition: all 0.3s;
}
input:focus {
border-color: #4a9eff;
background: #252f3f;
box-shadow: 0 0 20px rgba(74, 158, 255, 0.2);
}
input::placeholder { color: #5a6c7d; }
button {
padding: 18px 32px;
font-size: 18px;
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
}
button:hover {
background: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3);
}
button:active { transform: translateY(0); }
.error {
color: #ef4444;
margin-top: 20px;
font-size: 16px;
background: rgba(239, 68, 68, 0.1);
padding: 12px 20px;
border-radius: 6px;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.success {
margin-top: 30px;
padding: 20px;
background: rgba(34, 197, 94, 0.1);
border: 2px solid #22c55e;
border-radius: 8px;
font-size: 18px;
}
</style>
</head>
<body>
<div class="container">
{{if .Step2}}
<h1>Enter TOTP Code</h1>
<form method="POST" action="/verify-totp">
<div class="form-group">
<input type="text" name="totp" placeholder="000000" autofocus required pattern="[0-9]{6}" maxlength="6">
<button type="submit">Verify</button>
</div>
</form>
{{else}}
<h1>Enter User ID</h1>
<form method="POST" action="/verify-user">
<div class="form-group">
<input type="text" name="userid" placeholder="User ID" autofocus required>
<button type="submit">Continue</button>
</div>
</form>
{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
</div>
</body>
</html>`
const embeddedAppTemplate = `<!DOCTYPE html>
<html>
<head>
<title>REST API Client</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
color: #b8c5d6;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #2c3e50;
}
h1 {
font-size: 28px;
color: #4a9eff;
font-weight: 300;
}
.user-info {
display: flex;
gap: 15px;
align-items: center;
}
.username {
color: #b8c5d6;
font-size: 16px;
}
.logout-btn {
padding: 10px 20px;
background: #dc2626;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.logout-btn:hover {
background: #ef4444;
}
.request-form {
background: #1e2835;
padding: 25px;
border-radius: 10px;
margin-bottom: 20px;
border: 2px solid #2c3e50;
}
.form-row {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
label {
color: #8b9bb0;
font-size: 14px;
font-weight: 500;
}
select, input, textarea {
padding: 12px;
background: #252f3f;
border: 2px solid #2c3e50;
color: #e0e6ed;
border-radius: 6px;
font-size: 14px;
font-family: 'Courier New', monospace;
}
select:focus, input:focus, textarea:focus {
outline: none;
border-color: #4a9eff;
}
textarea {
resize: vertical;
min-height: 100px;
}
.headers-input {
font-size: 13px;
}
button {
padding: 12px 30px;
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
width: 100%;
}
button:hover {
background: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.response-section {
background: #1e2835;
padding: 25px;
border-radius: 10px;
border: 2px solid #2c3e50;
display: none;
}
.response-section.visible {
display: block;
}
.response-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #2c3e50;
}
.status {
font-size: 18px;
font-weight: 600;
}
.status.success { color: #22c55e; }
.status.error { color: #ef4444; }
.duration {
color: #8b9bb0;
font-size: 14px;
}
.response-body, .response-headers {
background: #252f3f;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
overflow-x: auto;
}
.response-body pre, .response-headers pre {
margin: 0;
color: #e0e6ed;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
.section-title {
color: #4a9eff;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
color: #ef4444;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>REST API Client</h1>
<div class="user-info">
<span class="username">👤 {{.UserID}}</span>
<a href="/logout" class="logout-btn">Logout</a>
</div>
</div>
<div class="request-form">
<div class="form-row">
<div class="form-group" style="flex: 0 0 120px;">
<label>Method</label>
<select id="method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>PATCH</option>
<option>DELETE</option>
</select>
</div>
<div class="form-group">
<label>URL</label>
<input type="text" id="url" placeholder="https://api.example.com/endpoint">
</div>
</div>
<div class="form-group">
<label>Headers (JSON format)</label>
<textarea id="headers" class="headers-input" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
</div>
<div class="form-group">
<label>Request Body</label>
<textarea id="body" placeholder='{"key": "value"}'></textarea>
</div>
<button onclick="sendRequest()" id="sendBtn">Send Request</button>
</div>
<div class="response-section" id="responseSection">
<div class="response-header">
<span class="status" id="status"></span>
<span class="duration" id="duration"></span>
</div>
<div id="errorMessage" class="error-message" style="display: none;"></div>
<div id="responseHeaders">
<div class="section-title">Response Headers</div>
<div class="response-headers">
<pre id="headersContent"></pre>
</div>
</div>
<div class="response-body">
<div class="section-title">Response Body</div>
<pre id="bodyContent"></pre>
</div>
</div>
</div>
<script>
async function sendRequest() {
const method = document.getElementById('method').value;
const url = document.getElementById('url').value;
const headersText = document.getElementById('headers').value;
const body = document.getElementById('body').value;
const sendBtn = document.getElementById('sendBtn');
const responseSection = document.getElementById('responseSection');
const errorMessage = document.getElementById('errorMessage');
if (!url) {
alert('Please enter a URL');
return;
}
let headers = {};
if (headersText.trim()) {
try {
headers = JSON.parse(headersText);
} catch (e) {
alert('Invalid JSON in headers');
return;
}
}
sendBtn.disabled = true;
sendBtn.textContent = 'Sending...';
responseSection.classList.remove('visible');
errorMessage.style.display = 'none';
try {
const response = await fetch('/api/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method, url, headers, body })
});
const result = await response.json();
responseSection.classList.add('visible');
if (result.error) {
document.getElementById('status').textContent = 'Error';
document.getElementById('status').className = 'status error';
errorMessage.textContent = result.error;
errorMessage.style.display = 'block';
document.getElementById('responseHeaders').style.display = 'none';
document.getElementById('bodyContent').textContent = '';
} else {
document.getElementById('status').textContent = 'Status: ' + result.status;
document.getElementById('status').className = result.status < 400 ? 'status success' : 'status error';
document.getElementById('responseHeaders').style.display = 'block';
const formattedHeaders = Object.entries(result.headers)
.map(([key, value]) => key + ': ' + value)
.join('\n');
document.getElementById('headersContent').textContent = formattedHeaders;
try {
const parsed = JSON.parse(result.body);
document.getElementById('bodyContent').textContent = JSON.stringify(parsed, null, 2);
} catch {
document.getElementById('bodyContent').textContent = result.body;
}
}
document.getElementById('duration').textContent = result.duration + 'ms';
} catch (error) {
responseSection.classList.add('visible');
document.getElementById('status').textContent = 'Request Failed';
document.getElementById('status').className = 'status error';
errorMessage.textContent = error.message;
errorMessage.style.display = 'block';
document.getElementById('responseHeaders').style.display = 'none';
} finally {
sendBtn.disabled = false;
sendBtn.textContent = 'Send Request';
}
}
// Allow Enter key in textareas
document.getElementById('url').addEventListener('keypress', function(e) {
if (e.key === 'Enter') sendRequest();
});
</script>
</body>
</html>`