Files
StarFleetCPTN 1995d19c3d 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.
2025-03-17 19:26:08 -07:00

325 lines
9.3 KiB
Go

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 (
// 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
)
// 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
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 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
// 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)
}
// Convert to hex string
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 plaintext codes for display and hashed codes for storage
return plainCodes, strings.Join(hashedCodes, ","), nil
}
// 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 hashed codes
hashedCodes := strings.Split(storedHashedCodes, ",")
// 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
}
}
return false
}
// RemoveBackupCode removes a used backup code from the list
func RemoveBackupCode(usedCode string, storedHashedCodes string) string {
if storedHashedCodes == "" {
return ""
}
usedCode = strings.ToLower(strings.ReplaceAll(usedCode, " ", ""))
hashedCodes := strings.Split(storedHashedCodes, ",")
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(remainingHashedCodes, ",")
}
// 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
}