mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 23:50:48 +02:00
- 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.
325 lines
9.3 KiB
Go
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
|
|
}
|