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:
- TOTP Secret Generation: Using
github.com/pquerna/otp/totplibrary - Server-Side QR Codes: Base64-encoded PNG images (removed external CDN dependency)
- Trusted Device Management: 30-day device persistence option
- Secure Cookie Handling:
Secureflag with proxy detection - Session Extension: 7-day session duration for improved UX
- 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)
}
Secure Cookie Handling
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.
Cookie Settings
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
/adminpaths 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
-
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
- Navigate to
-
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
-
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
-
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:
- Verify identity via email or SMS
- Require additional verification (security questions, biometrics)
- Generate temporary recovery code
- 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:
- Generate QR codes server-side to eliminate external dependencies
- Implement trusted device cookies with proper expiration
- Use Secure flag with proxy detection for cookie security
- Log all security events for auditing and compliance
- Provide recovery mechanisms to prevent lockout scenarios
- 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.
