Compare commits

...
10 Commits
Author SHA1 Message Date
StarFleetCPTN 83be259754 Merge pull request #37 from StarFleetCPTN/development
Implement Two-Factor Authentication (2FA) features and enhancements
2025-03-17 19:26:31 -07:00
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
StarFleetCPTN 2258bb1f70 Merge pull request #35 from StarFleetCPTN/development
Update README to include detailed Two-Factor Authentication (2FA) imp…
2025-03-17 05:25:33 -07:00
StarFleetCPTN 4254b529ef Update README to include detailed Two-Factor Authentication (2FA) implementation instructions and features. Added sections on setup process, login flow, and database changes related to 2FA support. 2025-03-17 05:24:53 -07:00
StarFleetCPTN a2971e3053 Merge pull request #34 from StarFleetCPTN/development
feat: Implement Two-Factor Authentication (2FA) functionality and Bug Fixes
2025-03-17 05:17:49 -07:00
StarFleetCPTN a7d912aa44 feat: Implement Two-Factor Authentication (2FA) functionality
- Added support for Two-Factor Authentication, including setup, verification, and disabling features.
- Introduced new components for 2FA setup and verification, enhancing user security.
- Updated user model to include fields for 2FA secret, status, and backup codes.
- Created migration to add necessary database fields for 2FA.
- Enhanced authentication handlers to manage 2FA processes during login and profile updates.
- Updated routes to include 2FA-related endpoints for setup and verification.
2025-03-17 05:17:03 -07:00
StarFleetCPTN 3dea48c691 Merge pull request #32 from StarFleetCPTN/development
refactor: Remove outdated migration for built-in auth fields and add …
2025-03-16 17:53:57 -07:00
StarFleetCPTN ef4bee88b8 refactor: Remove outdated migration for built-in auth fields and add new migration for updating Google Drive type
- Deleted the migration that updated the use_builtin_auth field to separate source and destination fields.
- Introduced a new migration to update source_type and destination_type from 'google_drive' to 'gdrive' in transfer_configs.
2025-03-16 17:50:43 -07:00
StarFleetCPTN 08d5f0edfc Merge pull request #31 from StarFleetCPTN/development
refactor: Update initial schema migration to correct table naming con…
2025-03-16 17:23:08 -07:00
StarFleetCPTN 1d43a7582d refactor: Update initial schema migration to correct table naming conventions
- Remove renaming logic for job_histories and password_histories tables, ensuring consistency with plural naming.
- Adjust CREATE TABLE statements to reflect the correct plural forms for job_histories and password_histories.
- Update drop table order to match the new naming conventions, maintaining foreign key constraints.
2025-03-16 17:14:14 -07:00
21 changed files with 1627 additions and 107 deletions
+50
View File
@@ -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:
@@ -340,6 +353,43 @@ User management features:
- JWT-based authentication with tokens
- User theme preference settings (light/dark)
### Two-Factor Authentication (2FA) Implementation
#### Overview
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
#### Features
- TOTP-based authentication (RFC 6238 compliant)
- QR code setup for easy enrollment
- Backup codes for account recovery
- Rate-limited verification attempts
- Secure secret storage
#### Database Changes
The following fields have been added to the `users` table:
- `two_factor_secret`: Stores the TOTP secret key
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
- `backup_codes`: Stores recovery backup codes
#### Setup Process
1. Navigate to `/profile/2fa/setup`
2. Scan the displayed QR code with your authenticator app
3. Enter the verification code to confirm setup
4. Save your backup codes in a secure location
#### Login Flow
1. Enter email and password as usual
2. If 2FA is enabled:
- 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**:
+145
View File
@@ -5,8 +5,112 @@ import (
"github.com/starfleetcptn/gomft/internal/db"
)
// Dialog component for 2FA disable confirmation
templ TwoFactorDisableDialog() {
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 pt-5 pb-3 text-center">
<div class="flex justify-center mb-2">
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
</div>
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
Disable Two-Factor Authentication
</h3>
</div>
<div class="px-6 py-4">
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
Are you sure you want to disable two-factor authentication? This will make your account less secure.
</p>
<div class="space-y-4">
<div>
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
<i class="fas fa-lock mr-1"></i> Current Password
</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="password"
id="current-password-2fa"
name="current_password"
class="form-input pl-10 w-full"
placeholder="Enter your current password"
required/>
</div>
</div>
<div id="disable-2fa-result"></div>
</div>
</div>
<div class="px-6 py-4 flex justify-end space-x-3">
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
Cancel
</button>
<button
type="button"
class="btn-danger"
onclick="submitDisable2FA()">
<i class="fas fa-times mr-1"></i>
Disable 2FA
</button>
</div>
</div>
</div>
}
templ Profile(ctx context.Context, user db.User) {
@LayoutWithContext("Profile", ctx) {
<script>
// Initialize dialog functionality
document.addEventListener('DOMContentLoaded', function() {
console.log('Initializing 2FA dialog functionality');
// Global functions for dialog control
window.hideDisable2FADialog = function() {
document.getElementById('disable-2fa-dialog').classList.add('hidden');
document.getElementById('current-password-2fa').value = '';
document.getElementById('disable-2fa-result').innerHTML = '';
};
window.showDisable2FADialog = function() {
console.log('Showing 2FA disable dialog');
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
};
window.submitDisable2FA = function() {
const password = document.getElementById('current-password-2fa').value;
if (!password) {
document.getElementById('disable-2fa-result').innerHTML = `
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
<span class="block sm:inline">Current password is required</span>
</div>`;
return;
}
htmx.ajax('POST', '/profile/2fa/disable', {
target: '#disable-2fa-result',
swap: 'innerHTML',
values: { current_password: password }
});
};
// Close dialog when clicking outside
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
if (e.target === this) {
hideDisable2FADialog();
}
});
// Close dialog on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
hideDisable2FADialog();
}
});
});
</script>
@TwoFactorDisableDialog()
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
@@ -45,6 +149,47 @@ templ Profile(ctx context.Context, user db.User) {
}
</dd>
</div>
<div class="flex flex-col sm:flex-row">
<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 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 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>
</div>
<div class="flex flex-col sm:flex-row">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
@@ -74,6 +74,7 @@ templ GooglePhotosDestinationForm() {
<input id="dest_read_only" name="dest_read_only" type="checkbox"
class="sr-only"
x-model="destReadOnly"
:value="destReadOnly ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
@@ -109,6 +110,7 @@ templ GooglePhotosDestinationForm() {
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
class="sr-only"
x-model="destIncludeArchived"
:value="destIncludeArchived ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
@@ -74,6 +74,7 @@ templ GooglePhotosSourceForm() {
<input id="source_read_only" name="source_read_only" type="checkbox"
class="sr-only"
x-model="sourceReadOnly"
:value="sourceReadOnly ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
@@ -109,6 +110,7 @@ templ GooglePhotosSourceForm() {
<input id="source_include_archived" name="source_include_archived" type="checkbox"
class="sr-only"
x-model="sourceIncludeArchived"
:value="sourceIncludeArchived ? 'true' : 'false'"
/>
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
+170
View File
@@ -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>
}
}
+85
View File
@@ -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>
}
}
+150
View File
@@ -0,0 +1,150 @@
package components
import "context"
type TwoFactorSetupData struct {
QRCodeURL string
Secret string
BackupCodes []string
ErrorMessage string
}
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
@LayoutWithContext("Two-Factor Authentication Setup", 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-8">
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</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>
}
<div class="space-y-8">
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<div class="flex justify-center mb-4">
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
</div>
<div class="text-center">
<p class="text-sm text-secondary-600 dark:text-secondary-400">
Can't scan the QR code? Use this code instead:
</p>
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
{ data.Secret }
</code>
</div>
</div>
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
<form
method="POST"
action="/profile/2fa/verify"
class="space-y-4"
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">
Enter the 6-digit code from your authenticator app
</label>
<input
type="text"
id="code"
name="code"
x-model="code"
class="form-input block w-full"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
required/>
</div>
<button
type="submit"
class="btn-primary w-full"
x-bind:disabled="code.length !== 6 || loading">
<span x-show="!loading">Verify and Enable 2FA</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>
</form>
</div>
if len(data.BackupCodes) > 0 {
<div>
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
</p>
<div class="grid grid-cols-2 gap-4 mb-4">
for _, code := range data.BackupCodes {
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
{ code }
</div>
}
</div>
<div class="text-center">
<button
class="btn-secondary"
onclick="downloadBackupCodes(this)">
<i class="fas fa-download mr-2"></i>
Download Backup Codes
</button>
<script>
function downloadBackupCodes(button) {
// Get backup codes from the displayed elements
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" +
"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. Keep these codes safe and secure.";
// 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.txt';
// Trigger download
document.body.appendChild(a);
a.click();
// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
</script>
</div>
</div>
}
</div>
</div>
</div>
</div>
}
}
+91
View File
@@ -0,0 +1,91 @@
package components
import "context"
type TwoFactorVerifyData struct {
ErrorMessage string
}
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
@LayoutWithContext("Two-Factor Authentication", 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-primary-100 dark:bg-primary-900 mb-4">
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
</div>
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from 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>
}
<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">
Authentication 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"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
placeholder="Enter 6-digit code"
required/>
</div>
</div>
<button
type="submit"
class="btn-primary w-full"
x-bind:disabled="code.length !== 6 || 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/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>
}
}
+3 -1
View File
@@ -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
+6
View File
@@ -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=
+324
View File
@@ -0,0 +1,324 @@
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
}
+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
@@ -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
}
+4 -27
View File
@@ -60,29 +60,6 @@ func InitialSchema() *gormigrate.Migration {
}
}()
// Change the table name from job_histories to job_history
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='job_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE job_histories RENAME TO job_history").Error; err != nil {
return err
}
}
// Change the table name from password_histories to password_history
count = 0
if err := tx.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='password_histories'").Scan(&count).Error; err != nil {
return err
}
if count > 0 {
if err := tx.Exec("ALTER TABLE password_histories RENAME TO password_history").Error; err != nil {
return err
}
}
// Create Users table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -101,7 +78,7 @@ func InitialSchema() *gormigrate.Migration {
}
// Create PasswordHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_history (
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS password_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
password_hash VARCHAR(255) NOT NULL,
@@ -212,7 +189,7 @@ func InitialSchema() *gormigrate.Migration {
}
// Create JobHistory table
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_history (
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS job_histories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
config_id INTEGER DEFAULT 0,
@@ -260,11 +237,11 @@ func InitialSchema() *gormigrate.Migration {
// Drop tables in reverse order to handle foreign key constraints
tables := []string{
"file_metadata",
"job_history",
"job_histories",
"jobs",
"transfer_configs",
"password_reset_tokens",
"password_history",
"password_histories",
"users",
}
for _, table := range tables {
@@ -1,49 +0,0 @@
package migrations
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateBuiltinAuthFields updates the use_builtin_auth field to separate source and destination fields
func UpdateBuiltinAuthFields() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "002_update_builtin_auth_fields",
Migrate: func(tx *gorm.DB) error {
// First, add the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_source BOOLEAN`).Error; err != nil {
return err
}
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth_dest BOOLEAN`).Error; err != nil {
return err
}
// Copy the old value to both new columns
if err := tx.Exec(`UPDATE transfer_configs SET
use_builtin_auth_source = use_builtin_auth,
use_builtin_auth_dest = use_builtin_auth`).Error; err != nil {
return err
}
// Drop the old column
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth`).Error
},
Rollback: func(tx *gorm.DB) error {
// Add back the original column
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN use_builtin_auth BOOLEAN`).Error; err != nil {
return err
}
// Copy the source value back (could also use dest, they should be the same)
if err := tx.Exec(`UPDATE transfer_configs SET use_builtin_auth = use_builtin_auth_source`).Error; err != nil {
return err
}
// Drop the new columns
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_source`).Error; err != nil {
return err
}
return tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN use_builtin_auth_dest`).Error
},
}
}
@@ -8,7 +8,7 @@ import (
// UpdateGDriveType updates the source_type and destination_type from 'google_drive' to 'gdrive'
func UpdateGDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "003_update_gdrive_type",
ID: "002_update_gdrive_type",
Migrate: func(tx *gorm.DB) error {
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
+80
View File
@@ -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
},
}
}
+1 -1
View File
@@ -9,8 +9,8 @@ import (
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
migrations := []*gormigrate.Migration{
InitialSchema(),
UpdateBuiltinAuthFields(),
UpdateGDriveType(),
Add2FA(),
}
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
+35 -17
View File
@@ -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")
}
+10
View File
@@ -10,6 +10,9 @@ 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("/login/backup-code", h.Handle2FABackupCodePage)
router.GET("/forgot-password", h.HandleForgotPasswordPage)
router.POST("/forgot-password", h.HandleForgotPassword)
router.GET("/reset-password", h.HandleResetPasswordPage)
@@ -22,6 +25,13 @@ 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("/profile/2fa/backup-codes", h.Handle2FABackupCodes)
authorized.POST("/profile/2fa/regenerate-codes", h.Handle2FARegenerateCodes)
{
authorized.GET("/dashboard", h.HandleDashboard)
authorized.GET("/configs", h.HandleConfigs)
@@ -0,0 +1,442 @@
package handlers
import (
"fmt"
"net/http"
"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"
)
// 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 - 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_hashed", backupCodesHashed, 3600, "/", "", false, true)
// Render setup page
data := components.TwoFactorSetupData{
QRCodeURL: qrCodeURL,
Secret: secret,
BackupCodes: backupCodesPlain, // Show plain codes to the user
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 - 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
}
// Verify the code
code := c.PostForm("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 {
c.String(http.StatusInternalServerError, "Failed to generate QR code")
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: 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": encryptedSecret, // Store the encrypted secret
"two_factor_enabled": true,
"backup_codes": backupCodesHashed, // Store the hashed codes
}).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_hashed", "", -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.Table("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(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Current password is required</span>
</div>`))
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(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Failed to get user information</span>
</div>`))
return
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Current password is incorrect</span>
</div>`))
return
}
// Check if 2FA is already disabled
if !user.TwoFactorEnabled {
c.Data(http.StatusBadRequest, "text/html", []byte(`<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Two-factor authentication is already disabled</span>
</div>`))
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(`<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Failed to disable two-factor authentication</span>
</div>`))
return
}
// Return success message
c.Data(http.StatusOK, "text/html", []byte(`<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4" role="alert">
<span class="block sm:inline">Two-factor authentication has been disabled</span>
<script>
setTimeout(function() {
window.location.reload();
}, 1500);
</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)
}