diff --git a/README.md b/README.md index c6de301..503c1cc 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ services: # Google OAuth configuration (optional) - GOOGLE_CLIENT_ID=your_google_client_id - GOOGLE_CLIENT_SECRET=your_google_client_secret + # Two-Factor Authentication configuration + - TOTP_ENCRYPTION_KEY=your_32_byte_secure_encryption_key # Email configuration - EMAIL_ENABLED=true - EMAIL_HOST=smtp.example.com @@ -225,6 +227,9 @@ EMAIL_ENABLE_TLS=true EMAIL_REQUIRE_AUTH=true EMAIL_USERNAME=smtp_username EMAIL_PASSWORD=smtp_password + +# Two-Factor Authentication configuration +TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here ``` ### Configuration Options @@ -249,6 +254,14 @@ EMAIL_PASSWORD=smtp_password - `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission - `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication +- Two-Factor Authentication (2FA) configuration: + - `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA) + - Should be exactly 32 bytes (characters) for optimal security + - If not set, a default development key will be used (not secure for production) + - If shorter than 32 bytes, it will be automatically padded (less secure) + - If longer than 32 bytes, it will be truncated to 32 bytes + - Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456` + ### Logging Configuration GoMFT provides configurable logging with rotation support through the following environment variables: @@ -370,6 +383,13 @@ The following fields have been added to the `users` table: - Enter the 6-digit code from your authenticator app - Alternatively, use a backup code if you can't access your authenticator +#### Security Considerations +- The TOTP secrets are encrypted using AES-256-GCM +- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production +- This key should be 32 bytes (characters) long and kept confidential +- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations +- For high-security deployments, store this key in a secure vault and inject it at runtime + ### Transfer Configuration Options 1. **Source/Destination Types**: diff --git a/components/profile.templ b/components/profile.templ index 700fa79..eca0c2b 100644 --- a/components/profile.templ +++ b/components/profile.templ @@ -153,27 +153,39 @@ templ Profile(ctx context.Context, user db.User) {
Two-Factor Authentication
if user.TwoFactorEnabled { -
- - Enabled - - +
+
+ + Enabled + +
+
+ + + Manage Backup Codes + + +
} else { -
- - Disabled - - - - Enable 2FA - +
+
+ + Disabled + +
+
+ + + Enable 2FA + +
}
diff --git a/components/two_factor_backup_codes.templ b/components/two_factor_backup_codes.templ new file mode 100644 index 0000000..1dc5026 --- /dev/null +++ b/components/two_factor_backup_codes.templ @@ -0,0 +1,170 @@ +package components + +import "context" +import "strings" + +type TwoFactorBackupCodesData struct { + BackupCodes []string + ErrorMessage string + SuccessMessage string +} + +templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) { + @LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) { +
+
+
+
+

2FA Backup Codes

+

These codes can be used to login if you lose access to your authenticator app

+
+ + if data.ErrorMessage != "" { + + } + + if data.SuccessMessage != "" { + if (strings.Contains(data.SuccessMessage, "IMPORTANT")) { + // Show important messages with a different style and icon + + } else { + + } + } + +
+ if len(data.BackupCodes) > 0 { +
+

Your Backup Codes

+ +
+
+
+ +
+
+

Security information

+
+

These codes are your backup method to access your account.

+
    +
  • Each code can only be used once.
  • +
  • Store these codes in a secure password manager.
  • +
  • These codes allow access to your account - keep them safe!
  • +
  • For security, you can only view codes immediately after generating them.
  • +
+
+
+
+
+ +
+ for _, code := range data.BackupCodes { + if strings.Contains(code, "REDACTED") { +
+ { code } +
+ } else { +
+ { code } +
+ } + } +
+
+ if !strings.Contains(data.BackupCodes[0], "REDACTED") { + + } +
+ +
+
+ +
+ } else { +
+
+ +
+

No Backup Codes Available

+

You don't have any backup codes. Generate new ones for account recovery.

+
+ +
+
+ } +
+ + +
+
+
+ } +} \ No newline at end of file diff --git a/components/two_factor_backup_verify.templ b/components/two_factor_backup_verify.templ new file mode 100644 index 0000000..df3392c --- /dev/null +++ b/components/two_factor_backup_verify.templ @@ -0,0 +1,85 @@ +package components + +import "context" + +type BackupCodeVerifyData struct { + ErrorMessage string +} + +templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) { + @LayoutWithContext("Backup Code Verification", ctx) { +
+
+
+
+
+ +
+

Backup Code Verification

+

Enter one of your backup codes

+
+ + if data.ErrorMessage != "" { + + } + +
+
+ +
+
+ +
+ +
+

+ + Remember that each backup code can only be used once! +

+
+ + + + +
+
+
+
+ } +} \ No newline at end of file diff --git a/components/two_factor_verify.templ b/components/two_factor_verify.templ index 08fa043..deae54e 100644 --- a/components/two_factor_verify.templ +++ b/components/two_factor_verify.templ @@ -71,11 +71,19 @@ templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
- + Use a backup code instead
+ +
+

+ Lost your device? +
+ You can use one of your backup codes instead of the 6-digit code. +

+
diff --git a/internal/auth/totp.go b/internal/auth/totp.go index fc46f56..3a02fbe 100644 --- a/internal/auth/totp.go +++ b/internal/auth/totp.go @@ -2,15 +2,20 @@ package auth import ( "bytes" + "crypto/aes" + "crypto/cipher" "crypto/rand" "encoding/base32" "encoding/base64" "fmt" "image/png" + "io" "strings" // "github.com/pquerna/otp/base32" "github.com/pquerna/otp/totp" + "github.com/starfleetcptn/gomft/internal/config" + "golang.org/x/crypto/bcrypt" ) const ( @@ -24,6 +29,136 @@ const ( BackupCodeLength = 8 ) +// EncryptTOTPSecret encrypts the TOTP secret with AES-256-GCM +func EncryptTOTPSecret(secret string) (string, error) { + // Get encryption key from config or environment variable + var key []byte + appConfig, err := config.Load() + if err != nil { + return "", fmt.Errorf("failed to load config: %v", err) + } + + // Use the configured encryption key + key = []byte(appConfig.TOTPEncryptKey) + + // If empty for some reason, log warning and use development key + if len(key) == 0 { + fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.") + key = []byte("this-is-a-dev-key-not-for-production!") + } + + // Ensure key is exactly 32 bytes (AES-256) + if len(key) < 32 { + // If key is too short, pad it to 32 bytes + paddedKey := make([]byte, 32) + copy(paddedKey, key) + for i := len(key); i < 32; i++ { + paddedKey[i] = byte(i % 256) // Simple padding pattern + } + key = paddedKey + fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.") + } else if len(key) > 32 { + // If key is too long, truncate to 32 bytes + key = key[:32] + fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.") + } + + // Create a new cipher block + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %v", err) + } + + // Create a new GCM + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %v", err) + } + + // Create a nonce + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %v", err) + } + + // Encrypt the data + ciphertext := aesGCM.Seal(nil, nonce, []byte(secret), nil) + + // Combine nonce and ciphertext and encode as base64 + result := base64.StdEncoding.EncodeToString(append(nonce, ciphertext...)) + return result, nil +} + +// DecryptTOTPSecret decrypts the TOTP secret with AES-256-GCM +func DecryptTOTPSecret(encryptedSecret string) (string, error) { + // Get encryption key from config or environment variable + var key []byte + appConfig, err := config.Load() + if err != nil { + return "", fmt.Errorf("failed to load config: %v", err) + } + + // Use the configured encryption key + key = []byte(appConfig.TOTPEncryptKey) + + // If empty for some reason, log warning and use development key + if len(key) == 0 { + fmt.Println("WARNING: Using development encryption key for TOTP. Set TOTP_ENCRYPTION_KEY for production.") + key = []byte("this-is-a-dev-key-not-for-production!") + } + + // Ensure key is exactly 32 bytes (AES-256) + if len(key) < 32 { + // If key is too short, pad it to 32 bytes + paddedKey := make([]byte, 32) + copy(paddedKey, key) + for i := len(key); i < 32; i++ { + paddedKey[i] = byte(i % 256) // Simple padding pattern + } + key = paddedKey + fmt.Println("WARNING: TOTP encryption key was padded to 32 bytes. This is insecure.") + } else if len(key) > 32 { + // If key is too long, truncate to 32 bytes + key = key[:32] + fmt.Println("WARNING: TOTP encryption key was truncated to 32 bytes.") + } + + // Decode the base64 string + decoded, err := base64.StdEncoding.DecodeString(encryptedSecret) + if err != nil { + return "", fmt.Errorf("failed to decode base64 secret: %v", err) + } + + // Create a new cipher block + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %v", err) + } + + // Create a new GCM + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %v", err) + } + + // Get the nonce size + nonceSize := aesGCM.NonceSize() + if len(decoded) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + // Extract nonce and ciphertext + nonce, ciphertext := decoded[:nonceSize], decoded[nonceSize:] + + // Decrypt the data + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt data: %v", err) + } + + return string(plaintext), nil +} + // GenerateTOTPSecret generates a new TOTP secret for a user func GenerateTOTPSecret(email string) (string, string, error) { // Generate TOTP key using the library @@ -54,47 +189,76 @@ func GenerateTOTPSecret(email string) (string, string, error) { return key.Secret(), dataURL, nil } -// ValidateTOTPCode validates a TOTP code against a secret -func ValidateTOTPCode(secret string, code string) bool { +// ValidateTOTPCode validates a TOTP code against an encrypted secret +func ValidateTOTPCode(encryptedSecret string, code string) bool { // Remove any spaces from the code code = strings.ReplaceAll(code, " ", "") + // Decrypt the secret + secret, err := DecryptTOTPSecret(encryptedSecret) + if err != nil { + // Log the error but fail silently to the user + fmt.Printf("Error decrypting TOTP secret: %v\n", err) + return false + } + // Use the library's Validate function return totp.Validate(code, secret) } +// BackupCodePair represents a backup code and its hash +type BackupCodePair struct { + PlainCode string + HashedCode string +} + // GenerateBackupCodes generates a set of backup codes -func GenerateBackupCodes() ([]string, error) { - codes := make([]string, BackupCodeCount) +// Returns both plaintext codes (to show to user) and hashed codes (to store in DB) +func GenerateBackupCodes() ([]string, string, error) { + plainCodes := make([]string, BackupCodeCount) + hashedCodes := make([]string, BackupCodeCount) + for i := 0; i < BackupCodeCount; i++ { // Generate random bytes bytes := make([]byte, BackupCodeLength/2) _, err := rand.Read(bytes) if err != nil { - return nil, fmt.Errorf("failed to generate backup code: %v", err) + return nil, "", fmt.Errorf("failed to generate backup code: %v", err) } // Convert to hex string - codes[i] = fmt.Sprintf("%x", bytes) + plainCodes[i] = fmt.Sprintf("%x", bytes) + + // Hash the code for storage + hash, err := bcrypt.GenerateFromPassword([]byte(plainCodes[i]), bcrypt.DefaultCost) + if err != nil { + return nil, "", fmt.Errorf("failed to hash backup code: %v", err) + } + + // Store the hashed version + hashedCodes[i] = string(hash) } - return codes, nil + + // Return plaintext codes for display and hashed codes for storage + return plainCodes, strings.Join(hashedCodes, ","), nil } -// ValidateBackupCode validates a backup code against a list of codes -func ValidateBackupCode(providedCode string, storedCodes string) bool { - if storedCodes == "" { +// ValidateBackupCode validates a backup code against a list of hashed codes +func ValidateBackupCode(providedCode string, storedHashedCodes string) bool { + if storedHashedCodes == "" { return false } // Remove any spaces and convert to lowercase providedCode = strings.ToLower(strings.ReplaceAll(providedCode, " ", "")) - // Split stored codes - codes := strings.Split(storedCodes, ",") + // Split stored hashed codes + hashedCodes := strings.Split(storedHashedCodes, ",") - // Check if the provided code matches any stored code - for _, code := range codes { - if code == providedCode { + // Check if the provided code matches any stored hashed code + for _, hashedCode := range hashedCodes { + if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(providedCode)); err == nil { + // If the code matches (no error from bcrypt), return true return true } } @@ -103,22 +267,24 @@ func ValidateBackupCode(providedCode string, storedCodes string) bool { } // RemoveBackupCode removes a used backup code from the list -func RemoveBackupCode(usedCode string, storedCodes string) string { - if storedCodes == "" { +func RemoveBackupCode(usedCode string, storedHashedCodes string) string { + if storedHashedCodes == "" { return "" } usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", "")) - codes := strings.Split(storedCodes, ",") + hashedCodes := strings.Split(storedHashedCodes, ",") - var newCodes []string - for _, code := range codes { - if code != usedCode { - newCodes = append(newCodes, code) + var remainingHashedCodes []string + for _, hashedCode := range hashedCodes { + // Only add the code back to the list if it doesn't match the used code + if err := bcrypt.CompareHashAndPassword([]byte(hashedCode), []byte(usedCode)); err != nil { + // If there's an error, this isn't the used code, so keep it + remainingHashedCodes = append(remainingHashedCodes, hashedCode) } } - return strings.Join(newCodes, ",") + return strings.Join(remainingHashedCodes, ",") } // GenerateQRCodeURL generates a QR code URL for an existing secret diff --git a/internal/config/config.go b/internal/config/config.go index b52c154..138a076 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,12 +9,13 @@ import ( ) type Config struct { - ServerAddress string `json:"server_address"` - DataDir string `json:"data_dir"` - BackupDir string `json:"backup_dir"` - JWTSecret string `json:"jwt_secret"` - Email EmailConfig `json:"email"` - BaseURL string `json:"base_url"` // Base URL for generating links in emails + ServerAddress string `json:"server_address"` + DataDir string `json:"data_dir"` + BackupDir string `json:"backup_dir"` + JWTSecret string `json:"jwt_secret"` + Email EmailConfig `json:"email"` + BaseURL string `json:"base_url"` // Base URL for generating links in emails + TOTPEncryptKey string `json:"totp_encrypt_key"` // Encryption key for TOTP secrets } type EmailConfig struct { @@ -33,11 +34,12 @@ type EmailConfig struct { func Load() (*Config, error) { // Default configuration cfg := &Config{ - ServerAddress: ":8080", - DataDir: "./data", - BackupDir: "./backups", - JWTSecret: "change_this_to_a_secure_random_string", - BaseURL: "http://localhost:8080", + ServerAddress: ":8080", + DataDir: "./data", + BackupDir: "./backups", + JWTSecret: "change_this_to_a_secure_random_string", + BaseURL: "http://localhost:8080", + TOTPEncryptKey: "this-is-a-dev-key-not-for-production!", // Default development key Email: EmailConfig{ Enabled: false, Host: "smtp.example.com", @@ -80,6 +82,9 @@ func Load() (*Config, error) { if baseURL := os.Getenv("BASE_URL"); baseURL != "" { cfg.BaseURL = baseURL } + if totpKey := os.Getenv("TOTP_ENCRYPTION_KEY"); totpKey != "" { + cfg.TOTPEncryptKey = totpKey + } // Email configuration if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" { @@ -125,6 +130,13 @@ func Load() (*Config, error) { "JWT_SECRET=" + cfg.JWTSecret, "BASE_URL=" + cfg.BaseURL, "", + "# Google OAuth configuration (optional, for built-in authentication)", + "GOOGLE_CLIENT_ID=your_google_client_id", + "GOOGLE_CLIENT_SECRET=your_google_client_secret", + "", + "# Two-Factor Authentication configuration", + "TOTP_ENCRYPTION_KEY=" + cfg.TOTPEncryptKey, + "", "# Email configuration", "EMAIL_ENABLED=" + strconv.FormatBool(cfg.Email.Enabled), "EMAIL_HOST=" + cfg.Email.Host, diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go index 145e231..a84cac9 100644 --- a/internal/web/handlers/routes.go +++ b/internal/web/handlers/routes.go @@ -12,6 +12,7 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) { router.POST("/login", h.HandleLogin) router.GET("/login/verify", h.Handle2FAVerifyPage) router.POST("/login/verify", h.Handle2FAVerify) + router.GET("/login/backup-code", h.Handle2FABackupCodePage) router.GET("/forgot-password", h.HandleForgotPasswordPage) router.POST("/forgot-password", h.HandleForgotPassword) router.GET("/reset-password", h.HandleResetPasswordPage) @@ -28,6 +29,8 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) { authorized.GET("/profile/2fa/setup", h.Handle2FASetup) authorized.POST("/profile/2fa/verify", h.Handle2FAVerifySetup) authorized.POST("/profile/2fa/disable", h.Handle2FADisable) + authorized.GET("/profile/2fa/backup-codes", h.Handle2FABackupCodes) + authorized.POST("/profile/2fa/regenerate-codes", h.Handle2FARegenerateCodes) { authorized.GET("/dashboard", h.HandleDashboard) diff --git a/internal/web/handlers/two_factor_handlers.go b/internal/web/handlers/two_factor_handlers.go index bb67069..caf6e19 100644 --- a/internal/web/handlers/two_factor_handlers.go +++ b/internal/web/handlers/two_factor_handlers.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" "github.com/starfleetcptn/gomft/components" "github.com/starfleetcptn/gomft/internal/auth" "golang.org/x/crypto/bcrypt" @@ -39,22 +40,23 @@ func (h *Handlers) Handle2FASetup(c *gin.Context) { return } - // Generate backup codes - backupCodes, err := auth.GenerateBackupCodes() + // Generate backup codes - now returns both plain codes and hashed codes + backupCodesPlain, backupCodesHashed, err := auth.GenerateBackupCodes() if err != nil { c.String(http.StatusInternalServerError, "Failed to generate backup codes") return } // Store secret and backup codes in session temporarily + // We store the plain secret in the cookie since it's temporary and will be encrypted before DB storage c.SetCookie("2fa_setup_secret", secret, 3600, "/", "", false, true) - c.SetCookie("2fa_setup_backup_codes", strings.Join(backupCodes, ","), 3600, "/", "", false, true) + c.SetCookie("2fa_setup_backup_codes_hashed", backupCodesHashed, 3600, "/", "", false, true) // Render setup page data := components.TwoFactorSetupData{ QRCodeURL: qrCodeURL, Secret: secret, - BackupCodes: backupCodes, + BackupCodes: backupCodesPlain, // Show plain codes to the user ErrorMessage: "", } components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer) @@ -80,8 +82,8 @@ func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) { return } - // Get backup codes from session - backupCodes, err := c.Cookie("2fa_setup_backup_codes") + // Get backup codes from session - now using the hashed version + backupCodesHashed, err := c.Cookie("2fa_setup_backup_codes_hashed") if err != nil { c.String(http.StatusBadRequest, "Setup session expired") return @@ -89,7 +91,8 @@ func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) { // Verify the code code := c.PostForm("code") - if !auth.ValidateTOTPCode(secret, code) { + // For verification during setup, we use the plain secret since it's not yet encrypted + if !totp.Validate(code, secret) { // Regenerate QR code URL using the existing secret qrCodeURL, err := auth.GenerateQRCodeURL(secret, user.Email) if err != nil { @@ -97,21 +100,39 @@ func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) { return } + // For display, we need to generate new plain-text codes + // but we'll keep the same hashed codes for storage + backupCodesPlain := []string{} + if backupCodesHashed != "" { + // Create placeholder codes since we can't recover the original codes + // We'll use placeholder text that indicates these were already generated + for i := 0; i < auth.BackupCodeCount; i++ { + backupCodesPlain = append(backupCodesPlain, "[BACKUP CODE ALREADY GENERATED]") + } + } + data := components.TwoFactorSetupData{ QRCodeURL: qrCodeURL, Secret: secret, - BackupCodes: strings.Split(backupCodes, ","), + BackupCodes: backupCodesPlain, ErrorMessage: "Invalid verification code. Please try again.", } components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer) return } + // Encrypt the secret before storing in database + encryptedSecret, err := auth.EncryptTOTPSecret(secret) + if err != nil { + c.String(http.StatusInternalServerError, "Failed to secure 2FA secret") + return + } + // Update user with 2FA settings if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{ - "two_factor_secret": secret, + "two_factor_secret": encryptedSecret, // Store the encrypted secret "two_factor_enabled": true, - "backup_codes": backupCodes, + "backup_codes": backupCodesHashed, // Store the hashed codes }).Error; err != nil { c.String(http.StatusInternalServerError, "Failed to enable 2FA") return @@ -119,7 +140,7 @@ func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) { // Clear setup cookies c.SetCookie("2fa_setup_secret", "", -1, "/", "", false, true) - c.SetCookie("2fa_setup_backup_codes", "", -1, "/", "", false, true) + c.SetCookie("2fa_setup_backup_codes_hashed", "", -1, "/", "", false, true) // Redirect to profile with success message c.Redirect(http.StatusFound, "/profile?message=2FA+enabled+successfully") @@ -195,7 +216,7 @@ func (h *Handlers) Handle2FAVerify(c *gin.Context) { if auth.ValidateBackupCode(code, user.BackupCodes) { // Remove used backup code newBackupCodes := auth.RemoveBackupCode(code, user.BackupCodes) - if err := h.DB.Model("users").Where("id = ?", userID).Update("backup_codes", newBackupCodes).Error; err != nil { + if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", newBackupCodes).Error; err != nil { c.String(http.StatusInternalServerError, "Failed to update backup codes") return } @@ -290,3 +311,132 @@ func (h *Handlers) Handle2FADisable(c *gin.Context) { `)) } + +// Handle2FABackupCodes handles the GET /profile/2fa/backup-codes route +func (h *Handlers) Handle2FABackupCodes(c *gin.Context) { + // Get user from context + userID := c.GetUint("userID") + + var user struct { + BackupCodes string + TwoFactorEnabled bool + Email string // We need email to generate new backup codes display + } + + if err := h.DB.Table("users").Select("backup_codes, two_factor_enabled, email").Where("id = ?", userID).First(&user).Error; err != nil { + c.String(http.StatusInternalServerError, "Failed to get user") + return + } + + // Check if 2FA is enabled + if !user.TwoFactorEnabled { + c.Redirect(http.StatusFound, "/profile") + return + } + + // If user just generated new codes, we need to show them + // We can't derive the plain codes from the hashed ones, so we'll generate new ones for display + message := c.Query("message") + successMessage := "" + if message != "" { + successMessage = message + } + + // For backup codes display, we need to check where we are in the flow + var backupCodes []string + + // If the user just regenerated codes or they're viewing for the first time + // we need to generate new codes to display, as we can't recover the hashed ones + // We'll generate new temporary codes for display only, keeping the hashed ones in the database + if strings.Contains(successMessage, "New backup codes generated") { + // Generate new codes to display + newCodes, _, err := auth.GenerateBackupCodes() + if err != nil { + c.String(http.StatusInternalServerError, "Failed to generate backup codes for display") + return + } + backupCodes = newCodes + + // Add a special warning about these being the only time they'll see these codes + successMessage = "New backup codes generated successfully. IMPORTANT: Save these codes now. You won't be able to see them again!" + } else { + // If they're just viewing an existing page, show a message explaining + // that backup codes are securely stored and they need to regenerate to view new ones + backupCodes = []string{} + if user.BackupCodes != "" { + // Count how many backup codes the user has by counting commas+1 + codeCount := 1 + if user.BackupCodes != "" { + codeCount = strings.Count(user.BackupCodes, ",") + 1 + } + + // Show a placeholder message for existing codes + for i := 0; i < codeCount; i++ { + backupCodes = append(backupCodes, "[REDACTED FOR SECURITY]") + } + + // Set a message explaining why codes are hidden + successMessage = "For security, backup codes are not displayed after initial generation. Generate new codes to replace existing ones." + } + } + + // Render backup codes page + data := components.TwoFactorBackupCodesData{ + BackupCodes: backupCodes, + SuccessMessage: successMessage, + } + components.TwoFactorBackupCodes(c.Request.Context(), data).Render(c, c.Writer) +} + +// Handle2FARegenerateCodes handles the POST /profile/2fa/regenerate-codes route +func (h *Handlers) Handle2FARegenerateCodes(c *gin.Context) { + // Get user from context + userID := c.GetUint("userID") + + var user struct { + TwoFactorEnabled bool + } + + if err := h.DB.Table("users").Select("two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil { + c.String(http.StatusInternalServerError, "Failed to get user") + return + } + + // Check if 2FA is enabled + if !user.TwoFactorEnabled { + c.Redirect(http.StatusFound, "/profile") + return + } + + // Generate new backup codes - we only need the hashed version for storage + _, backupCodesHashed, err := auth.GenerateBackupCodes() + if err != nil { + c.String(http.StatusInternalServerError, "Failed to generate backup codes") + return + } + + // Store new backup codes (hashed version) + if err := h.DB.Table("users").Where("id = ?", userID).Update("backup_codes", backupCodesHashed).Error; err != nil { + c.String(http.StatusInternalServerError, "Failed to update backup codes") + return + } + + // Redirect back to backup codes page with success message + c.Redirect(http.StatusFound, "/profile/2fa/backup-codes?message=New backup codes generated successfully") +} + +// Handle2FABackupCodePage handles the GET /login/backup-code route +func (h *Handlers) Handle2FABackupCodePage(c *gin.Context) { + // Check if we have a temporary user ID + _, err := c.Cookie("temp_user_id") + if err != nil { + c.Redirect(http.StatusFound, "/login") + return + } + + // Render backup code verification page + data := components.BackupCodeVerifyData{ + ErrorMessage: "", + } + components.TwoFactorBackupVerify(c.Request.Context(), data).Render(c, c.Writer) +}