diff --git a/components/providers/destination/gphotos.templ b/components/providers/destination/gphotos.templ
index 4398c37..637c99d 100644
--- a/components/providers/destination/gphotos.templ
+++ b/components/providers/destination/gphotos.templ
@@ -74,6 +74,7 @@ templ GooglePhotosDestinationForm() {
+
+
+
+
Set Up Two-Factor Authentication
+
Enhance your account security with 2FA
+
+
+ if data.ErrorMessage != "" {
+
+
+
+ { data.ErrorMessage }
+
+
+ }
+
+
+
+
1. Scan QR Code
+
+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
+
+
+

+
+
+
+ Can't scan the QR code? Use this code instead:
+
+
+ { data.Secret }
+
+
+
+
+
+
+ if len(data.BackupCodes) > 0 {
+
+
3. Save Backup Codes
+
+ Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
+
+
+ for _, code := range data.BackupCodes {
+
+ { code }
+
+ }
+
+
+
+
+
+
+ }
+
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/components/two_factor_verify.templ b/components/two_factor_verify.templ
new file mode 100644
index 0000000..08fa043
--- /dev/null
+++ b/components/two_factor_verify.templ
@@ -0,0 +1,83 @@
+package components
+
+import "context"
+
+type TwoFactorVerifyData struct {
+ ErrorMessage string
+}
+
+templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
+ @LayoutWithContext("Two-Factor Authentication", ctx) {
+
+
+
+
+
+
+
+
Two-Factor Authentication
+
Enter the code from your authenticator app
+
+
+ if data.ErrorMessage != "" {
+
+
+
+ { data.ErrorMessage }
+
+
+ }
+
+
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 66e8af5..80fee0b 100644
--- a/go.mod
+++ b/go.mod
@@ -4,11 +4,13 @@ go 1.24.0
require (
github.com/a-h/templ v0.3.833
+ github.com/gin-contrib/sessions v1.0.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-gormigrate/gormigrate/v2 v2.1.3
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
+ github.com/pquerna/otp v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.35.0
@@ -17,13 +19,13 @@ require (
)
require (
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.12.9 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
- github.com/gin-contrib/sessions v1.0.2 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
diff --git a/go.sum b/go.sum
index f9cec7f..7d2c387 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,7 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.12.9 h1:Od1BvK55NnewtGaJsTDeAOSnLVO2BTSLOe0+ooKokmQ=
github.com/bytedance/sonic v1.12.9/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -42,6 +44,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -77,6 +81,8 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
+github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
diff --git a/internal/auth/totp.go b/internal/auth/totp.go
new file mode 100644
index 0000000..fc46f56
--- /dev/null
+++ b/internal/auth/totp.go
@@ -0,0 +1,158 @@
+package auth
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/base32"
+ "encoding/base64"
+ "fmt"
+ "image/png"
+ "strings"
+
+ // "github.com/pquerna/otp/base32"
+ "github.com/pquerna/otp/totp"
+)
+
+const (
+ // IssuerName is the name of the issuer that appears in authenticator apps
+ IssuerName = "GoMFT"
+ // SecretSize is the size of the TOTP secret in bytes
+ SecretSize = 20
+ // BackupCodeCount is the number of backup codes to generate
+ BackupCodeCount = 8
+ // BackupCodeLength is the length of each backup code
+ BackupCodeLength = 8
+)
+
+// GenerateTOTPSecret generates a new TOTP secret for a user
+func GenerateTOTPSecret(email string) (string, string, error) {
+ // Generate TOTP key using the library
+ key, err := totp.Generate(totp.GenerateOpts{
+ Issuer: IssuerName,
+ AccountName: email,
+ })
+ if err != nil {
+ return "", "", fmt.Errorf("failed to generate TOTP key: %v", err)
+ }
+
+ // Generate QR code image
+ var buf bytes.Buffer
+ img, err := key.Image(256, 256)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to generate QR code image: %v", err)
+ }
+
+ // Encode image as PNG and convert to base64
+ err = png.Encode(&buf, img)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to encode QR code image: %v", err)
+ }
+
+ // Create data URL
+ dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
+
+ return key.Secret(), dataURL, nil
+}
+
+// ValidateTOTPCode validates a TOTP code against a secret
+func ValidateTOTPCode(secret string, code string) bool {
+ // Remove any spaces from the code
+ code = strings.ReplaceAll(code, " ", "")
+
+ // Use the library's Validate function
+ return totp.Validate(code, secret)
+}
+
+// GenerateBackupCodes generates a set of backup codes
+func GenerateBackupCodes() ([]string, error) {
+ codes := 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)
+ }
+
+ // Convert to hex string
+ codes[i] = fmt.Sprintf("%x", bytes)
+ }
+ return codes, nil
+}
+
+// ValidateBackupCode validates a backup code against a list of codes
+func ValidateBackupCode(providedCode string, storedCodes string) bool {
+ if storedCodes == "" {
+ return false
+ }
+
+ // Remove any spaces and convert to lowercase
+ providedCode = strings.ToLower(strings.ReplaceAll(providedCode, " ", ""))
+
+ // Split stored codes
+ codes := strings.Split(storedCodes, ",")
+
+ // Check if the provided code matches any stored code
+ for _, code := range codes {
+ if code == providedCode {
+ return true
+ }
+ }
+
+ return false
+}
+
+// RemoveBackupCode removes a used backup code from the list
+func RemoveBackupCode(usedCode string, storedCodes string) string {
+ if storedCodes == "" {
+ return ""
+ }
+
+ usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", ""))
+ codes := strings.Split(storedCodes, ",")
+
+ var newCodes []string
+ for _, code := range codes {
+ if code != usedCode {
+ newCodes = append(newCodes, code)
+ }
+ }
+
+ return strings.Join(newCodes, ",")
+}
+
+// GenerateQRCodeURL generates a QR code URL for an existing secret
+func GenerateQRCodeURL(secret string, email string) (string, error) {
+ // Decode the base32 secret
+ secretBytes, err := base32.StdEncoding.DecodeString(secret)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode secret: %v", err)
+ }
+
+ key, err := totp.Generate(totp.GenerateOpts{
+ Issuer: IssuerName,
+ AccountName: email,
+ Secret: secretBytes,
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to generate TOTP key: %v", err)
+ }
+
+ // Generate QR code image
+ var buf bytes.Buffer
+ img, err := key.Image(256, 256)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate QR code image: %v", err)
+ }
+
+ // Encode image as PNG and convert to base64
+ err = png.Encode(&buf, img)
+ if err != nil {
+ return "", fmt.Errorf("failed to encode QR code image: %v", err)
+ }
+
+ // Create data URL
+ dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
+
+ return dataURL, nil
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 0b773bf..a8d9397 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -25,6 +25,9 @@ type User struct {
AccountLocked *bool `gorm:"default:false"`
LockoutUntil *time.Time
Theme string `gorm:"default:'light'"`
+ TwoFactorSecret string `gorm:"type:varchar(32)"`
+ TwoFactorEnabled bool `gorm:"default:false"`
+ BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
CreatedAt time.Time
UpdatedAt time.Time
}
diff --git a/internal/db/migrations/003_add_2fa.go b/internal/db/migrations/003_add_2fa.go
new file mode 100644
index 0000000..cadc56d
--- /dev/null
+++ b/internal/db/migrations/003_add_2fa.go
@@ -0,0 +1,80 @@
+package migrations
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+// Add2FA creates a migration for adding Two-Factor Authentication fields
+func Add2FA() *gormigrate.Migration {
+ return &gormigrate.Migration{
+ ID: "003_add_2fa",
+ Migrate: func(tx *gorm.DB) error {
+ // Check if any tables exist (indicating an existing database)
+ var count int64
+ if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
+ return fmt.Errorf("failed to check for existing tables: %v", err)
+ }
+
+ // If tables exist, create a backup
+ if count > 0 {
+ // Get the database path
+ sqlDB, err := tx.DB()
+ if err != nil {
+ return fmt.Errorf("failed to get underlying database: %v", err)
+ }
+
+ var seq int
+ var name, dbPath string
+ if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
+ return fmt.Errorf("failed to get database path: %v", err)
+ }
+
+ // Create backup file with timestamp
+ backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
+
+ // Read original database
+ data, err := os.ReadFile(dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to read database for backup: %v", err)
+ }
+
+ // Write backup
+ if err := os.WriteFile(backupFile, data, 0600); err != nil {
+ return fmt.Errorf("failed to create database backup: %v", err)
+ }
+
+ fmt.Printf("Created database backup at: %s\n", backupFile)
+ }
+
+ // Add new columns for 2FA - one at a time for SQLite compatibility
+ if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_secret VARCHAR(32)`).Error; err != nil {
+ return err
+ }
+ if err := tx.Exec(`ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE`).Error; err != nil {
+ return err
+ }
+ if err := tx.Exec(`ALTER TABLE users ADD COLUMN backup_codes TEXT`).Error; err != nil {
+ return err
+ }
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ // Remove 2FA columns - one at a time for SQLite compatibility
+ if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_secret`).Error; err != nil {
+ return err
+ }
+ if err := tx.Exec(`ALTER TABLE users DROP COLUMN two_factor_enabled`).Error; err != nil {
+ return err
+ }
+ if err := tx.Exec(`ALTER TABLE users DROP COLUMN backup_codes`).Error; err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+}
diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go
index 7b642ef..3a231aa 100644
--- a/internal/db/migrations/migrations.go
+++ b/internal/db/migrations/migrations.go
@@ -10,6 +10,7 @@ func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
InitialSchema(),
UpdateGDriveType(),
+ Add2FA(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
diff --git a/internal/web/handlers/auth_handlers.go b/internal/web/handlers/auth_handlers.go
index 4544703..20fda6e 100644
--- a/internal/web/handlers/auth_handlers.go
+++ b/internal/web/handlers/auth_handlers.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
+ "fmt"
"log"
"net/http"
"strings"
@@ -56,11 +57,21 @@ func (h *Handlers) AuthMiddleware() gin.HandlerFunc {
return
}
+ // Safely extract claims with type assertions and defaults
+ userID, _ := claims["user_id"].(float64)
+ email, _ := claims["email"].(string)
+ username, _ := claims["username"].(string)
+ isAdmin, _ := claims["is_admin"].(bool)
+
// Set user information in the context
- c.Set("userID", uint(claims["user_id"].(float64)))
- c.Set("email", claims["email"].(string))
- c.Set("username", claims["username"].(string))
- c.Set("isAdmin", claims["is_admin"].(bool))
+ c.Set("userID", uint(userID))
+ if email != "" {
+ c.Set("email", email)
+ }
+ if username != "" {
+ c.Set("username", username)
+ }
+ c.Set("isAdmin", isAdmin)
c.Next()
}
@@ -142,10 +153,11 @@ func (h *Handlers) APIAdminMiddleware() gin.HandlerFunc {
}
// GenerateJWT generates a JWT token for the given user
-func (h *Handlers) GenerateJWT(userID uint, username string, isAdmin bool) (string, error) {
+func (h *Handlers) GenerateJWT(userID uint, email string, isAdmin bool) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
- "username": username,
+ "email": email,
+ "username": strings.Split(email, "@")[0], // Use email prefix as username
"is_admin": isAdmin,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
@@ -243,24 +255,30 @@ func (h *Handlers) HandleLogin(c *gin.Context) {
return
}
- // Generate JWT token with all necessary user information
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
- "user_id": user.ID,
- "email": user.Email,
- "username": strings.Split(user.Email, "@")[0], // Use email prefix as username
- "is_admin": user.IsAdmin,
- "exp": time.Now().Add(time.Hour * 24).Unix(),
- })
+ // Check if 2FA is enabled
+ if user.TwoFactorEnabled {
+ // Store user ID temporarily for 2FA verification
+ c.SetCookie("temp_user_id", fmt.Sprintf("%d", user.ID), 300, "/", "", false, true) // 5 minutes expiry
- // Sign the token
- tokenString, err := token.SignedString([]byte(h.JWTSecret))
+ // Redirect to 2FA verification page
+ c.Redirect(http.StatusFound, "/login/verify")
+ return
+ }
+
+ // If 2FA is not enabled, proceed with normal login
+ // Generate JWT token with all necessary user information
+ isAdmin := false
+ if user.IsAdmin != nil {
+ isAdmin = *user.IsAdmin
+ }
+ token, err := h.GenerateJWT(user.ID, user.Email, isAdmin)
if err != nil {
components.Login(components.CreateTemplateContext(c), "Authentication error").Render(c, c.Writer)
return
}
// Set token in cookie
- c.SetCookie("jwt_token", tokenString, 86400, "/", "", false, true)
+ c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
c.Redirect(http.StatusFound, "/dashboard")
}
diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go
index e57ca11..145e231 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -10,6 +10,8 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
router.GET("/", h.HandleHome)
router.GET("/login", h.HandleLoginPage)
router.POST("/login", h.HandleLogin)
+ router.GET("/login/verify", h.Handle2FAVerifyPage)
+ router.POST("/login/verify", h.Handle2FAVerify)
router.GET("/forgot-password", h.HandleForgotPasswordPage)
router.POST("/forgot-password", h.HandleForgotPassword)
router.GET("/reset-password", h.HandleResetPasswordPage)
@@ -22,6 +24,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
// Password change route - only accessed from profile page
authorized.POST("/change-password", h.HandleChangePassword)
+ // 2FA routes - under profile
+ authorized.GET("/profile/2fa/setup", h.Handle2FASetup)
+ authorized.POST("/profile/2fa/verify", h.Handle2FAVerifySetup)
+ authorized.POST("/profile/2fa/disable", h.Handle2FADisable)
+
{
authorized.GET("/dashboard", h.HandleDashboard)
authorized.GET("/configs", h.HandleConfigs)
diff --git a/internal/web/handlers/two_factor_handlers.go b/internal/web/handlers/two_factor_handlers.go
new file mode 100644
index 0000000..bb67069
--- /dev/null
+++ b/internal/web/handlers/two_factor_handlers.go
@@ -0,0 +1,292 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/starfleetcptn/gomft/components"
+ "github.com/starfleetcptn/gomft/internal/auth"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// Handle2FASetup handles the GET /profile/2fa/setup route
+func (h *Handlers) Handle2FASetup(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+
+ var user struct {
+ Email string
+ TwoFactorEnabled bool
+ }
+
+ if err := h.DB.Table("users").Select("email, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
+ c.String(http.StatusInternalServerError, "Failed to get user")
+ return
+ }
+
+ // Check if 2FA is already enabled
+ if user.TwoFactorEnabled {
+ c.Redirect(http.StatusFound, "/profile")
+ return
+ }
+
+ // Generate TOTP secret and QR code URL
+ secret, qrCodeURL, err := auth.GenerateTOTPSecret(user.Email)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to generate 2FA secret")
+ return
+ }
+
+ // Generate backup codes
+ backupCodes, err := auth.GenerateBackupCodes()
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to generate backup codes")
+ return
+ }
+
+ // Store secret and backup codes in session temporarily
+ c.SetCookie("2fa_setup_secret", secret, 3600, "/", "", false, true)
+ c.SetCookie("2fa_setup_backup_codes", strings.Join(backupCodes, ","), 3600, "/", "", false, true)
+
+ // Render setup page
+ data := components.TwoFactorSetupData{
+ QRCodeURL: qrCodeURL,
+ Secret: secret,
+ BackupCodes: backupCodes,
+ ErrorMessage: "",
+ }
+ components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
+}
+
+// Handle2FAVerifySetup handles the POST /profile/2fa/verify route
+func (h *Handlers) Handle2FAVerifySetup(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+
+ var user struct {
+ Email string
+ }
+ if err := h.DB.Table("users").Select("email").Where("id = ?", userID).First(&user).Error; err != nil {
+ c.String(http.StatusInternalServerError, "Failed to get user")
+ return
+ }
+
+ // Get secret from session
+ secret, err := c.Cookie("2fa_setup_secret")
+ if err != nil {
+ c.String(http.StatusBadRequest, "Setup session expired")
+ return
+ }
+
+ // Get backup codes from session
+ backupCodes, err := c.Cookie("2fa_setup_backup_codes")
+ if err != nil {
+ c.String(http.StatusBadRequest, "Setup session expired")
+ return
+ }
+
+ // Verify the code
+ code := c.PostForm("code")
+ if !auth.ValidateTOTPCode(secret, code) {
+ // Regenerate QR code URL using the existing secret
+ qrCodeURL, err := auth.GenerateQRCodeURL(secret, user.Email)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to generate QR code")
+ return
+ }
+
+ data := components.TwoFactorSetupData{
+ QRCodeURL: qrCodeURL,
+ Secret: secret,
+ BackupCodes: strings.Split(backupCodes, ","),
+ ErrorMessage: "Invalid verification code. Please try again.",
+ }
+ components.TwoFactorSetup(c.Request.Context(), data).Render(c, c.Writer)
+ 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_enabled": true,
+ "backup_codes": backupCodes,
+ }).Error; err != nil {
+ c.String(http.StatusInternalServerError, "Failed to enable 2FA")
+ return
+ }
+
+ // Clear setup cookies
+ c.SetCookie("2fa_setup_secret", "", -1, "/", "", false, true)
+ c.SetCookie("2fa_setup_backup_codes", "", -1, "/", "", false, true)
+
+ // Redirect to profile with success message
+ c.Redirect(http.StatusFound, "/profile?message=2FA+enabled+successfully")
+}
+
+// Handle2FAVerifyPage handles the GET /login/verify route
+func (h *Handlers) Handle2FAVerifyPage(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 verification page
+ data := components.TwoFactorVerifyData{
+ ErrorMessage: "",
+ }
+ components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
+}
+
+// Handle2FAVerify handles the POST /login/verify route
+func (h *Handlers) Handle2FAVerify(c *gin.Context) {
+ // Get user ID from cookie
+ tempUserID, err := c.Cookie("temp_user_id")
+ if err != nil {
+ c.Redirect(http.StatusFound, "/login")
+ return
+ }
+
+ // Parse user ID
+ var userID uint
+ if _, err := fmt.Sscanf(tempUserID, "%d", &userID); err != nil {
+ c.Redirect(http.StatusFound, "/login")
+ return
+ }
+
+ var user struct {
+ TwoFactorSecret string
+ BackupCodes string
+ Email string
+ IsAdmin *bool
+ }
+ if err := h.DB.Table("users").Select("two_factor_secret, backup_codes, email, is_admin").Where("id = ?", userID).First(&user).Error; err != nil {
+ c.Redirect(http.StatusFound, "/login")
+ return
+ }
+
+ code := c.PostForm("code")
+
+ // First try TOTP code
+ if auth.ValidateTOTPCode(user.TwoFactorSecret, code) {
+ // Generate new JWT token and set cookie
+ isAdmin := false
+ if user.IsAdmin != nil {
+ isAdmin = *user.IsAdmin
+ }
+ token, err := h.GenerateJWT(userID, user.Email, isAdmin)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to generate token")
+ return
+ }
+ c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
+
+ // Clear temporary user ID cookie
+ c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
+
+ c.Redirect(http.StatusFound, "/dashboard")
+ return
+ }
+
+ // Then try backup code
+ 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 {
+ c.String(http.StatusInternalServerError, "Failed to update backup codes")
+ return
+ }
+
+ // Generate new JWT token and set cookie
+ isAdmin := false
+ if user.IsAdmin != nil {
+ isAdmin = *user.IsAdmin
+ }
+ token, err := h.GenerateJWT(userID, user.Email, isAdmin)
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to generate token")
+ return
+ }
+ c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
+
+ // Clear temporary user ID cookie
+ c.SetCookie("temp_user_id", "", -1, "/", "", false, true)
+
+ c.Redirect(http.StatusFound, "/dashboard")
+ return
+ }
+
+ // If neither code is valid, show error
+ data := components.TwoFactorVerifyData{
+ ErrorMessage: "Invalid verification code. Please try again.",
+ }
+ components.TwoFactorVerify(c.Request.Context(), data).Render(c, c.Writer)
+}
+
+// Handle2FADisable handles the POST /profile/2fa/disable route
+func (h *Handlers) Handle2FADisable(c *gin.Context) {
+ // Get user ID from context
+ userID := c.GetUint("userID")
+
+ // Get current password from form
+ currentPassword := c.PostForm("current_password")
+ if currentPassword == "" {
+ c.Data(http.StatusBadRequest, "text/html", []byte(`
+ Current password is required
+
`))
+ return
+ }
+
+ // Get user from database
+ var user struct {
+ PasswordHash string
+ TwoFactorEnabled bool
+ }
+ if err := h.DB.Table("users").Select("password_hash, two_factor_enabled").Where("id = ?", userID).First(&user).Error; err != nil {
+ c.Data(http.StatusInternalServerError, "text/html", []byte(`
+ Failed to get user information
+
`))
+ return
+ }
+
+ // Verify current password
+ if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
+ c.Data(http.StatusBadRequest, "text/html", []byte(`
+ Current password is incorrect
+
`))
+ return
+ }
+
+ // Check if 2FA is already disabled
+ if !user.TwoFactorEnabled {
+ c.Data(http.StatusBadRequest, "text/html", []byte(`
+ Two-factor authentication is already disabled
+
`))
+ return
+ }
+
+ // Disable 2FA
+ if err := h.DB.Table("users").Where("id = ?", userID).Updates(map[string]interface{}{
+ "two_factor_enabled": false,
+ "two_factor_secret": nil,
+ "backup_codes": nil,
+ }).Error; err != nil {
+ c.Data(http.StatusInternalServerError, "text/html", []byte(`
+ Failed to disable two-factor authentication
+
`))
+ return
+ }
+
+ // Return success message
+ c.Data(http.StatusOK, "text/html", []byte(`
+ Two-factor authentication has been disabled
+
+
`))
+}