Implement Two-Factor Authentication (2FA) features and enhancements

- Added configuration options for Two-Factor Authentication, including TOTP encryption key.
- Updated user profile components to manage 2FA status and backup codes.
- Implemented encryption and decryption of TOTP secrets using AES-256-GCM.
- Enhanced backup code generation and validation processes, storing hashed codes securely.
- Introduced new routes and handlers for managing backup codes and 2FA setup.
- Updated README with detailed instructions and security considerations for 2FA implementation.
This commit is contained in:
StarFleetCPTN committed 2025-03-17 19:26:08 -07:00
1 parent 2258bb1f70
commit 1995d19c3d
9 files changed
+692 -66

No files matched your search

+189 -23
View File
@@ -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
+23 -11
View File
@@ -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,
+3
View File
@@ -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)
+162 -12
View File
@@ -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) {
</script>
</div>`))
}
// 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)
}