Skip to content

Two-Factor Authentication & Admin Security

Implementing Two-Factor Authentication (2FA) is one of the most effective security measures you can add to any administrative interface. In this article, we’ll explore a production-ready TOTP implementation from the email-server project, covering everything from secure QR code generation to device persistence and audit logging.


Overview of the Implementation

The email-server project implements TOTP-based 2FA with the following features:

  1. TOTP Secret Generation: Using github.com/pquerna/otp/totp library
  2. Server-Side QR Codes: Base64-encoded PNG images (removed external CDN dependency)
  3. Trusted Device Management: 30-day device persistence option
  4. Secure Cookie Handling: Secure flag with proxy detection
  5. Session Extension: 7-day session duration for improved UX
  6. Audit Logging: All admin actions logged for compliance

Database Schema (Migration 007)

The implementation starts with database schema changes to support TOTP:

-- Add TOTP secret and enabled flag to users
ALTER TABLE users ADD COLUMN totp_secret TEXT;
ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0;

-- Table to track verified 2FA devices (persisted for 30 days)
CREATE TABLE IF NOT EXISTS totp_trusted_devices (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    device_token TEXT NOT NULL UNIQUE,
    device_name TEXT,
    ip_address TEXT,
    user_agent TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    expires_at DATETIME NOT NULL,
    last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- Indexes for quick lookups
CREATE INDEX IF NOT EXISTS idx_totp_trusted_devices_user ON totp_trusted_devices(user_id);
CREATE INDEX IF NOT EXISTS idx_totp_trusted_devices_token ON totp_trusted_devices(device_token);
CREATE INDEX IF NOT EXISTS idx_totp_trusted_devices_expires ON totp_trusted_devices(expires_at);

TOTP Implementation

Secret Generation

The system generates TOTP secrets using standard TOTP parameters:

// @filename: main.go
const (
    totpIssuer = "MailServer Admin"
)

func (s *Server) generateTOTPSecret(username string) (*otp.Key, error) {
    return totp.Generate(totp.GenerateOpts{
        Issuer:      totpIssuer,
        AccountName: username,
        Period:      30,                   // 30-second codes
        Digits:      otp.DigitsSix,        // 6-digit codes
        Algorithm:   otp.AlgorithmSHA1,    // SHA1 is standard
    })
}

Security Note: SHA1 is the default algorithm for TOTP compatibility with most authenticator apps. While SHA1 has known weaknesses, the time-based window (30 seconds) makes brute force attacks impractical.

Code Validation

Validating TOTP codes is straightforward:

// @filename: main.go
func (s *Server) validateTOTPCode(secret, code string) bool {
    return totp.Validate(code, secret)
}

The totp.Validate function automatically handles time drift and accepts codes from adjacent time windows, allowing for slight clock synchronization issues.


QR Code Generation

Server-Side Generation (Security Fix)

Initially, the implementation used an external CDN JavaScript library for QR code generation. This was a security vulnerability as it introduced an external dependency that could be compromised or go offline.

Commit b175bc5 fixed this by generating QR codes server-side:

// @filename: handlers.go
func generateQRCodeBase64(key *otp.Key) (string, error) {
    img, err := key.Image(200, 200)
    if err != nil {
        return "", err
    }

    var buf bytes.Buffer
    if err := png.Encode(&buf, img); err != nil {
        return "", err
    }

    return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}

Template Integration

The QR code is embedded directly in the HTML as a base64 data URI:

<div
  style="background: white; padding: 1rem; border-radius: 8px; display: inline-block;"
>
  <img
    src="data:image/png;base64,{{.QRCode}}"
    alt="QR Code"
    width="200"
    height="200"
  />
</div>

Benefits of Server-Side Generation:

  • No external dependencies: Eliminates CDN reliability issues
  • Faster page load: No additional HTTP request
  • Security: No risk of malicious JavaScript from CDN
  • Privacy: QR code generated entirely server-side

2FA Setup Flow

The setup process guides users through enabling 2FA:

// @filename: query.sql
func (s *Server) handle2FASetup(w http.ResponseWriter, r *http.Request) {
    userID, ok := s.getSessionUserID(r)
    if !ok {
        http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
        return
    }

    // Get username for this user
    var username string
    err := s.db.QueryRow("SELECT username FROM users WHERE id = ?", userID).Scan(&username)
    if err != nil {
        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title": "Two-Factor Setup",
            "Error": "Failed to get user information",
        })
        return
    }

    status, err := s.getTwoFactorStatus(userID)
    if err != nil {
        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title": "Two-Factor Setup",
            "Error": "Failed to get 2FA status",
        })
        return
    }

    if r.Method == http.MethodGet {
        if status.Enabled {
            // Show disable option
            s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
                "Title":     "Two-Factor Authentication",
                "Enabled":   true,
                "CSRFToken": w.Header().Get("X-CSRF-Token"),
            })
            return
        }

        // Generate new secret
        key, err := s.generateTOTPSecret(username)
        if err != nil {
            s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
                "Title": "Two-Factor Setup",
                "Error": "Failed to generate 2FA secret",
            })
            return
        }

        // Generate QR code as base64
        qrCodeBase64, err := generateQRCodeBase64(key)
        if err != nil {
            s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
                "Title": "Two-Factor Setup",
                "Error": "Failed to generate QR code",
            })
            return
        }

        // Store secret temporarily (not enabled yet)
        _, err = s.db.Exec(
            "UPDATE users SET totp_secret = ? WHERE id = ?",
            key.Secret(), userID,
        )
        if err != nil {
            s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
                "Title": "Two-Factor Setup",
                "Error": "Failed to save 2FA secret",
            })
            return
        }

        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title":       "Two-Factor Setup",
            "Enabled":     false,
            "Secret":      key.Secret(),
            "QRCode":      qrCodeBase64,
            "AccountName": username,
            "CSRFToken":   w.Header().Get("X-CSRF-Token"),
        })
        return
    }

    // POST - verify and enable 2FA
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    code := r.FormValue("code")

    // Get user's TOTP secret
    var secret string
    err = s.db.QueryRow("SELECT totp_secret FROM users WHERE id = ?", userID).Scan(&secret)
    if err != nil || secret == "" {
        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title": "Two-Factor Setup",
            "Error": "Please refresh and try again",
        })
        return
    }

    if !s.validateTOTPCode(secret, code) {
        // Generate QR code from existing secret for retry
        key, err := otp.NewKeyFromURL("otpauth://totp/" + totpIssuer + ":" + username + "?secret=" + secret + "&issuer=" + totpIssuer)
        var qrCodeBase64 string
        if err == nil {
            qrCodeBase64, _ = generateQRCodeBase64(key)
        }
        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title":       "Two-Factor Setup",
            "Enabled":     false,
            "Secret":      secret,
            "QRCode":      qrCodeBase64,
            "AccountName": username,
            "Error":       "Invalid verification code. Please try again.",
            "CSRFToken":   w.Header().Get("X-CSRF-Token"),
        })
        return
    }

    // Enable 2FA
    _, err = s.db.Exec("UPDATE users SET totp_enabled = 1 WHERE id = ?", userID)
    if err != nil {
        s.renderTemplate(w, "2fa_setup.html", map[string]interface{}{
            "Title": "Two-Factor Setup",
            "Error": "Failed to enable 2FA",
        })
        return
    }

    // Create trusted device for current session
    token, err := s.createTrustedDevice(userID, r)
    if err == nil {
        http.SetCookie(w, &http.Cookie{
            Name:     trustedDeviceCookie,
            Value:    token,
            Path:     "/admin",
            HttpOnly: true,
            Secure:   isSecureContext(r),
            SameSite: http.SameSiteStrictMode,
            MaxAge:   trustedDeviceDays * 24 * 60 * 60,
        })
    }

    s.auditLogger.Log(r.Context(), username, audit.EventConfigChange, "2FA enabled", nil, getIP(r))
    http.Redirect(w, r, "/admin/2fa/setup?enabled=1", http.StatusSeeOther)
}

Trusted Device Management

Device Token Generation

// @filename: handlers.go
const trustedDeviceCookie = "totp_trusted"
const trustedDeviceDays = 30

func generateDeviceToken() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(b), nil
}

func (s *Server) createTrustedDevice(userID int64, r *http.Request) (string, error) {
    token, err := generateDeviceToken()
    if err != nil {
        return "", err
    }

    expiresAt := time.Now().AddDate(0, 0, trustedDeviceDays)
    deviceName := r.UserAgent()
    if len(deviceName) > 200 {
        deviceName = deviceName[:200]
    }

    _, err = s.db.Exec(`
        INSERT INTO totp_trusted_devices (user_id, device_token, device_name, ip_address, user_agent, expires_at)
        VALUES (?, ?, ?, ?, ?, ?)`,
        userID, token, deviceName, getIP(r), r.UserAgent(), expiresAt,
    )
    if err != nil {
        return "", err
    }

    return token, nil
}

Device Verification

// @filename: query.sql
func (s *Server) checkTrustedDevice(userID int64, token string) bool {
    if token == "" {
        return false
    }

    var count int
    err := s.db.QueryRow(`
        SELECT COUNT(*) FROM totp_trusted_devices
        WHERE user_id = ? AND device_token = ? AND expires_at > datetime('now')`,
        userID, token,
    ).Scan(&count)

    if err != nil || count == 0 {
        return false
    }

    // Update last used time
    s.db.Exec(`
        UPDATE totp_trusted_devices SET last_used_at = datetime('now')
        WHERE user_id = ? AND device_token = ?`,
        userID, token,
    )

    return true
}

Checking 2FA Requirement

// @filename: main.go
func (s *Server) needs2FAVerification(r *http.Request, userID int64) bool {
    status, err := s.getTwoFactorStatus(userID)
    if err != nil || !status.Enabled {
        return false
    }

    // Check for trusted device cookie
    cookie, err := r.Cookie(trustedDeviceCookie)
    if err != nil {
        return true
    }

    return !s.checkTrustedDevice(userID, cookie.Value)
}

Proxy Detection

The isSecureContext function properly detects when the application is running behind a reverse proxy:

// @filename: handlers.go
func isSecureContext(r *http.Request) bool {
    // Direct TLS connection
    if r.TLS != nil {
        return true
    }
    // Check if behind HTTPS proxy
    if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
        return true
    }
    // In production with nginx, always return true
    // This is safer than potentially setting Secure=false
    return true
}

Why this matters: When behind a reverse proxy like nginx, the application receives HTTP connections even though the user is accessing it over HTTPS. The X-Forwarded-Proto header is set by the proxy to indicate the original protocol.

All authentication cookies use secure settings:

// @filename: main.go
http.SetCookie(w, &http.Cookie{
    Name:     "admin_session",
    Value:    token,
    Path:     "/admin",
    HttpOnly: true,                    // Prevents JavaScript access
    Secure:   isSecureContext(r),      // Only sent over HTTPS
    SameSite: http.SameSiteStrictMode,  // CSRF protection
    MaxAge:   86400,                   // 24 hours
})

Security attributes explained:

  • HttpOnly: Prevents XSS attacks from stealing the cookie
  • Secure: Cookie only sent over HTTPS connections
  • SameSite=Strict: Prevents CSRF attacks by only sending cookies on same-site requests
  • Path: Restricts cookie to /admin paths only

Session Management

Extended Session Duration

Commit 8722071 extended session duration from 24 hours to 7 days to improve user experience:

// @filename: main.go
func (s *Server) createSession(userID int64) string {
    token := generateToken()
    now := time.Now()
    expiresAt := now.Add(7 * 24 * time.Hour) // 7 days session

    // Store in database
    _, err := s.db.Exec(
        `INSERT INTO admin_sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
        token, userID, now, expiresAt,
    )
    if err != nil {
        s.logger.Error("Failed to create session in database", "error", err.Error())
        return ""
    }

    return token
}

Trade-off Consideration: Longer sessions improve UX but increase the window of opportunity if a session is stolen. This is mitigated by:

  • 2FA enforcement: Even with a valid session, new browsers require 2FA
  • IP validation: Sessions bound to IP addresses (optional enhancement)
  • Activity monitoring: Audit logs track all actions

Audit Logging

Event Types

The audit system tracks all security-relevant events:

// @filename: main.go
type EventType string

const (
    EventUserCreate     EventType = "user.create"
    EventUserDelete     EventType = "user.delete"
    EventUserUpdate     EventType = "user.update"
    EventPasswordChange EventType = "password.change"
    EventDomainCreate   EventType = "domain.create"
    EventDomainDelete   EventType = "domain.delete"
    EventLoginSuccess   EventType = "login.success"
    EventLoginFailure   EventType = "login.failure"
    EventSieveUpdate    EventType = "sieve.update"
    EventQueueRetry     EventType = "queue.retry"
    EventQueueDelete    EventType = "queue.delete"
    EventConfigChange   EventType = "config.change"
)

Logging Implementation

// @filename: main.go
func (s *Server) handle2FAVerify(w http.ResponseWriter, r *http.Request) {
    // ... validation code ...

    clientIP := s.rateLimiter.GetClientIP(r)

    if !s.validateTOTPCode(status.Secret, code) {
        s.rateLimiter.RecordFailure(clientIP)
        s.auditLogger.Log(r.Context(), pending.Username, audit.EventLoginFailure, "2FA verification failed", nil, clientIP)

        s.renderTemplate(w, "2fa_verify.html", map[string]interface{}{
            "Title":     "Two-Factor Verification",
            "Username":  pending.Username,
            "Error":     "Invalid verification code",
            "CSRFToken": w.Header().Get("X-CSRF-Token"),
        })
        return
    }

    // Success
    s.rateLimiter.RecordSuccess(clientIP)
    s.logger.Info("Admin login successful (2FA)", "ip", clientIP, "username", pending.Username)
    s.auditLogger.Log(r.Context(), pending.Username, audit.EventLoginSuccess, "2FA verified", nil, clientIP)

    http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}

Audit Query

// @filename: query.sql
func (l *Logger) Query(ctx context.Context, filter QueryFilter) ([]Event, error) {
    query := `SELECT id, timestamp, actor, action, target, details, ip_address FROM audit_log WHERE 1=1`
    args := []interface{}{}

    if filter.Actor != "" {
        query += " AND actor = ?"
        args = append(args, filter.Actor)
    }
    if filter.Action != "" {
        query += " AND action = ?"
        args = append(args, string(filter.Action))
    }
    if filter.Target != "" {
        // Escape SQL LIKE wildcards to prevent injection
        query += " AND target LIKE ? ESCAPE '\\'"
        args = append(args, "%"+escapeLikeWildcards(filter.Target)+"%")
    }
    if !filter.StartTime.IsZero() {
        query += " AND timestamp >= ?"
        args = append(args, filter.StartTime)
    }
    if !filter.EndTime.IsZero() {
        query += " AND timestamp <= ?"
        args = append(args, filter.EndTime)
    }

    query += " ORDER BY timestamp DESC"

    if filter.Limit > 0 {
        query += " LIMIT ?"
        args = append(args, filter.Limit)
    } else {
        query += " LIMIT 100" // Default limit
    }

    if filter.Offset > 0 {
        query += " OFFSET ?"
        args = append(args, filter.Offset)
    }

    rows, err := l.db.QueryContext(ctx, query, args...)
    // ... process results ...
}

Security Considerations for TOTP

1. Secret Storage

// Secrets are stored encrypted or hashed in production
// Use bcrypt or Argon2 for additional protection

2. Rate Limiting

// @filename: main.go
// Limit verification attempts to prevent brute force
const maxAttempts = 5
const lockoutDuration = 15 * time.Minute

3. Time Synchronization

TOTP requires accurate time synchronization. The library handles:

  • ±1 time window (30 seconds before/after)
  • Configurable skew tolerance
  • Grace period for clock drift

4. Backup Codes

Not implemented in current version but recommended:

Generate 10 one-time backup codes for account recovery:

// @filename: handlers.go
func generateBackupCodes() []string {
    codes := make([]string, 10)
    for i := range codes {
        b := make([]byte, 8)
        rand.Read(b)
        codes[i] = fmt.Sprintf("%04x-%04x", binary.BigEndian.Uint16(b[:2]), binary.BigEndian.Uint16(b[2:4]))
    }
    return codes
}

5. Device Limit

Consider limiting the number of trusted devices:

// @filename: query.sql
const maxTrustedDevices = 5

func (s *Server) createTrustedDevice(userID int64, r *http.Request) (string, error) {
    // Check device count
    var count int
    s.db.QueryRow("SELECT COUNT(*) FROM totp_trusted_devices WHERE user_id = ? AND expires_at > datetime('now')", userID).Scan(&count)

    if count >= maxTrustedDevices {
        return "", fmt.Errorf("maximum trusted devices reached")
    }

    // ... rest of function ...
}

Testing 2FA Implementation

Manual Testing Steps

  1. Setup Flow:

    • Navigate to /admin/2fa/setup
    • Verify QR code displays correctly
    • Scan QR code with authenticator app
    • Enter code to verify
    • Confirm 2FA is enabled
  2. Login Flow:

    • Logout and attempt to login
    • Verify 2FA verification page appears
    • Enter valid TOTP code
    • Verify successful login
    • Test with “Remember this device” option
  3. Trusted Device:

    • Enable “Remember this device”
    • Logout and login again
    • Verify no 2FA prompt (device trusted)
    • Wait 30 days or manually clear cookie
    • Verify 2FA prompt returns
  4. Failed Attempts:

    • Enter invalid TOTP codes
    • Verify error message
    • Monitor audit logs for failures

Automated Testing

// @filename: handlers.go
func TestTOTPGeneration(t *testing.T) {
    key, err := totp.Generate(totp.GenerateOpts{
        Issuer:      "Test",
        AccountName: "test@example.com",
        Period:      30,
        Digits:      6,
        Algorithm:   otp.AlgorithmSHA1,
    })

    assert.NoError(t, err)
    assert.NotEmpty(t, key.Secret())
    assert.NotEmpty(t, key.URL())
}

func TestTOTPValidation(t *testing.T) {
    key, _ := totp.Generate(totp.GenerateOpts{
        Issuer:      "Test",
        AccountName: "test@example.com",
    })

    // Generate current code
    code, _ := totp.GenerateCode(key.Secret(), time.Now())

    // Validate should succeed
    valid := totp.Validate(code, key.Secret())
    assert.True(t, valid)

    // Invalid code should fail
    invalid := totp.Validate("000000", key.Secret())
    assert.False(t, invalid)
}

Recovery Options

When implementing 2FA, always provide recovery mechanisms:

Backup Codes

Generate and display 10 one-time codes during setup:

1. ABCD-1234-EFGH-5678
2. IJKL-9012-MNOP-3456
3. QRST-7890-UVWX-1234
...

Store hashed versions in the database, mark as used when consumed.

Admin Recovery

For admin accounts, provide a secure recovery flow:

  1. Verify identity via email or SMS
  2. Require additional verification (security questions, biometrics)
  3. Generate temporary recovery code
  4. Force password reset on next login

Emergency Recovery Tokens

Generate emergency recovery tokens during setup:

// @filename: handlers.go
func generateEmergencyToken() string {
    b := make([]byte, 32)
    rand.Read(b)
    return "EMERGENCY-" + base64.URLEncoding.EncodeToString(b)
}

Store securely and provide to user offline (printed, stored in password manager).


Conclusion

Implementing TOTP-based 2FA is a critical security measure for any administrative interface. The email-server project demonstrates a production-ready implementation with:

  • Secure QR code generation without external dependencies
  • Trusted device management for improved UX
  • Secure cookie handling with proper proxy detection
  • Extended session duration balanced with security
  • Comprehensive audit logging for compliance

Key takeaways for your implementation:

  1. Generate QR codes server-side to eliminate external dependencies
  2. Implement trusted device cookies with proper expiration
  3. Use Secure flag with proxy detection for cookie security
  4. Log all security events for auditing and compliance
  5. Provide recovery mechanisms to prevent lockout scenarios
  6. Test thoroughly including edge cases and failure scenarios

2FA is not optional for production admin interfaces. Invest the time to implement it correctly, and your security posture will significantly improve.

security 2fa totp go authentication
Share:

Continue Reading

Implementing Two-Factor Authentication (2FA) in Node.js with TOTP and Google Authenticator

Two-Factor Authentication (2FA) is an extra layer of security that requires not only a password and username but also something that only the user has on them, typically a one-time code generated by an app like Google Authenticator. This guide walks you through implementing 2FA in a Node.js application using TOTP, Google Authenticator, and otplib.

Read article
Node.jsJavaScriptBackend

Implementing Email Verification in Node.js with JWT and Nodemailer

Email verification is essential for securing the registration process in web applications. It ensures that users are genuine and prevents spam or fraudulent sign-ups. By combining JWT (JSON Web Token) with Nodemailer, you can implement email verification efficiently in a Node.js application. This guide will walk you through setting up email verification, covering JWT generation for verification links, sending emails, and verifying users.

Read article
Node.jsJavaScriptBackend