mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
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:
@@ -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**:
|
||||
|
||||
+31
-19
@@ -153,27 +153,39 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
if user.TwoFactorEnabled {
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger btn-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
||||
<i class="fas fa-key mr-1"></i>
|
||||
Manage Backup Codes
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger btn-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="badge badge-warning">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
||||
</span>
|
||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
||||
<i class="fas fa-lock mr-1"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-warning">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
||||
<i class="fas fa-lock mr-1"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@@ -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) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
// Show important messages with a different style and icon
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
||||
|
||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-center space-x-4 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-8">
|
||||
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
|
||||
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
|
||||
</div>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Backup Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Remember that each backup code can only be used once!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,19 @@ templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Lost your device?
|
||||
<br/>
|
||||
You can use one of your backup codes instead of the 6-digit code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+189
-23
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user