Compare commits

...
10 Commits
Author SHA1 Message Date
StarFleetCPTN aa04c76e1f Merge pull request #45 from StarFleetCPTN/development
Enhance Docker and Build Configuration
2025-03-18 21:23:42 -07:00
StarFleetCPTN 1981764f80 Enhance Docker and Build Configuration
- Added support for build arguments in Dockerfile to specify UID, GID, version, build time, and commit hash.
- Updated docker-compose.yaml to utilize UID and GID for non-root user execution, improving file permission handling.
- Introduced entrypoint.sh script to manage user permissions and environment setup at container startup.
- Enhanced application logging to include version information during startup.
- Updated README with new instructions for running the application with specific user IDs and environment variables.
2025-03-18 21:23:15 -07:00
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
30 changed files with 2128 additions and 118 deletions
@@ -26,6 +26,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -61,5 +79,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+24
View File
@@ -27,6 +27,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to get all tags for versioning
# Set version information
- name: Set Version
id: version
run: |
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=$VERSION" >> $GITHUB_ENV
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
echo "VERSION=$VERSION" >> $GITHUB_ENV
fi
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
# Set up Docker Buildx for efficient builds
- name: Set up Docker Buildx
@@ -62,5 +80,11 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.VERSION }}
BUILD_TIME=${{ env.BUILD_TIME }}
COMMIT=${{ env.COMMIT }}
UID=1000
GID=1000
cache-from: type=gha
cache-to: type=gha,mode=max
+13 -7
View File
@@ -57,23 +57,29 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
# Also set build timestamp for versioning
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Build for multiple platforms
run: |
mkdir -p dist
# Define common ldflags with version information
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT"
# Linux builds
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
# macOS builds
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
# Windows builds
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
# Create checksums
cd dist
+34 -3
View File
@@ -2,6 +2,11 @@ FROM golang:1.24-alpine AS builder
WORKDIR /app
# Accept build arguments for version information
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG COMMIT=unknown
# Install build dependencies
RUN apk add --no-cache git build-base
@@ -18,8 +23,10 @@ COPY . .
# Generate template files from .templ files
RUN templ generate
# Build the application
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
# Compile the application with version information
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft
# Install rclone
RUN apk add --no-cache curl unzip && \
@@ -34,10 +41,21 @@ RUN apk add --no-cache curl unzip && \
# Create a smaller runtime image
FROM alpine:3.19
# Add arguments for UID and GID with defaults
ARG UID=1000
ARG GID=1000
ARG USERNAME=gomft
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite bash
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
&& apk add --no-cache --virtual .user-deps \
shadow curl xz
# Create user and group with specified IDs
RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binary from the builder stage
COPY --from=builder /app/gomft /app/
@@ -47,14 +65,27 @@ COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
COPY static/ /app/static/
COPY components/ /app/components/
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create data and backup directories
RUN mkdir -p /app/data /app/backups
# Create a placeholder .env file with proper permissions
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions
RUN chmod +x /app/gomft
# Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app
# Expose the application port
EXPOSE 8080
# Use our entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Run the application
CMD ["/app/gomft"]
+191 -24
View File
@@ -9,6 +9,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
---
## Screenshots
@@ -29,6 +30,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
</tr>
</table>
---
## Features
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
@@ -80,12 +83,16 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
- **Portable Deployment**: Run on any platform that supports Docker or Go
---
## Prerequisites
- Go 1.21 or later
- rclone installed and configured
- SQLite 3
---
## Installation
### Standard Installation
@@ -116,6 +123,8 @@ docker pull starfleetcptn/gomft:latest
```
2. Run the container:
#### Basic run
```bash
docker run -d \
--name gomft \
@@ -125,6 +134,42 @@ docker run -d \
starfleetcptn/gomft:latest
```
#### Run with specific user ID and group ID (using environment variables)
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
#### Or specify user IDs directly
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-e PUID=1001 \
-e PGID=1001 \
starfleetcptn/gomft:latest
```
#### Using a .env file for configuration
```bash
docker run -d \
--name gomft \
-p 8080:8080 \
-v /path/to/data:/app/data \
-v /path/to/backups:/app/backups \
-v /path/to/.env:/app/.env \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
starfleetcptn/gomft:latest
```
3. Access the web interface at `http://localhost:8080`
#### Docker Compose Example
@@ -143,17 +188,19 @@ services:
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./.env:/app/.env
environment:
- PUID=1000
- PGID=1000
- TZ=UTC
- SERVER_ADDRESS=:8080
- DATA_DIR=/app/data
- BACKUP_DIR=/app/backups
- JWT_SECRET=change_this_to_a_secure_random_string
- BASE_URL=http://localhost:8080
# Google OAuth configuration (optional)
- GOOGLE_CLIENT_ID=your_google_client_id
- GOOGLE_CLIENT_SECRET=your_google_client_secret
# Email configuration
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
- EMAIL_ENABLED=true
- EMAIL_HOST=smtp.example.com
- EMAIL_PORT=587
@@ -163,34 +210,14 @@ services:
- EMAIL_REQUIRE_AUTH=true
- EMAIL_USERNAME=smtp_username
- EMAIL_PASSWORD=smtp_password
# Logging configuration
- LOGS_DIR=/app/data/logs
- LOG_MAX_SIZE=10
- LOG_MAX_BACKUPS=5
- LOG_MAX_AGE=30
- LOG_COMPRESS=true
- LOG_LEVEL=info
# The user directive is no longer needed when using PUID/PGID environment variables
```
Alternatively, you can mount your own .env file to the container:
```yaml
version: '3'
services:
gomft:
image: starfleetcptn/gomft:latest
container_name: gomft
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./backups:/app/backups
- ./.env:/app/.env
environment:
- TZ=UTC
```
Save this as `docker-compose.yml` and run:
```bash
@@ -199,11 +226,14 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
---
## Configuration
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
```
```ini
# Basic configuration
SERVER_ADDRESS=:8080
DATA_DIR=/app/data
BACKUP_DIR=/app/backups
@@ -225,6 +255,13 @@ 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
# UserID and GroupID
PUID=1000
PGID=1000
```
### Configuration Options
@@ -249,6 +286,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:
@@ -265,6 +310,8 @@ GoMFT provides configurable logging with rotation support through the following
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
---
## Usage
1. Start the server:
@@ -340,6 +387,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**:
@@ -502,6 +586,8 @@ The Admin Tools interface also includes database management capabilities:
- View system statistics
- Optimize the database with maintenance tools
---
## Development
### Project Structure
@@ -552,6 +638,8 @@ templ generate
air
```
---
## Contributing
1. Fork the repository
@@ -560,6 +648,8 @@ air
4. Push to the branch
5. Create a Pull Request
---
## Directory Structure
GoMFT uses the following directory structure:
@@ -580,6 +670,83 @@ volumes:
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
---
## Security Considerations
### Running as a Non-Root User
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
#### Benefits of Running as Non-Root
- **Improved Security**: Limits the potential damage if the container is compromised
- **Better File Permissions**: Files created by the container will match your host user permissions
- **Compliance**: Many security policies and best practices require containers to run as non-root
#### Methods to Run as Non-Root
1. **Using PUID/PGID environment variables (recommended)**:
```bash
# Using current user's ID
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
# Or in docker-compose.yml
environment:
- PUID=1000
- PGID=1000
```
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
2. **Using the `--user` flag with Docker run**:
```bash
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
```
3. **Using Docker Compose with environment variables for `user` directive**:
```yaml
services:
gomft:
image: starfleetcptn/gomft:latest
user: "${UID:-1000}:${GID:-1000}"
```
4. **Building a custom image with specified UID/GID**:
```yaml
services:
gomft:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
```
#### Environment Variables for User Management
| Variable | Description | Default |
|----------|-------------|---------|
| `PUID` | User ID to run as | Built-in user ID (1000) |
| `PGID` | Group ID to run as | Built-in group ID (1000) |
| `USERNAME` | Username to use | `gomft` |
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
#### Volume Permissions
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
```bash
# Create directories with correct ownership
mkdir -p data backups
chown -R $(id -u):$(id -g) data backups
# Or adjust permissions to allow the container user to write
mkdir -p data backups
chmod -R 777 data backups # Less secure, but easier for testing
```
---
## License
+103
View File
@@ -513,11 +513,96 @@ templ AdminTools(ctx context.Context, data AdminToolsData) {
{ fmt.Sprint(data.TotalJobs) }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Application Version</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
<div class="flex items-center">
<span>{ AppVersion }</span>
if AppVersion == "dev" {
<a href="https://github.com/starfleetcptn/gomft/releases"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else if AppVersion == "1.0.0" {
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.0.0"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else if AppVersion == "1.1.0" {
<a href="https://github.com/starfleetcptn/gomft/releases/tag/v1.1.0"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
} else {
<a href="https://github.com/starfleetcptn/gomft/releases"
class="ml-2 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
target="_blank" rel="noopener">
<i class="fas fa-external-link-alt text-xs"></i>
</a>
}
</div>
</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Build Information -->
<div class="mt-6">
<div class="card">
<div class="card-header">
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
<i class="fas fa-code-branch mr-2 text-primary-500"></i>
Build Information
</h3>
</div>
<div class="card-body">
<div class="relative overflow-hidden bg-secondary-50 dark:bg-secondary-900 rounded-lg">
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-3">
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Version</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ AppVersion }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Build Time</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ getBuildTime() }
</dd>
</div>
<div class="sm:col-span-1">
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Commit</dt>
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
{ getCommit() }
</dd>
</div>
</dl>
<div class="mt-6 flex justify-end">
<a
href="https://github.com/starfleetcptn/gomft"
class="inline-flex items-center px-4 py-2 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
target="_blank" rel="noopener"
>
<i class="fab fa-github mr-2"></i>
View on GitHub
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Log Viewer -->
<div id="logs-container" class="mt-8">
@AdminLogViewer(data)
@@ -897,3 +982,21 @@ templ AdminLogContent(data AdminToolsData) {
}
</div>
}
// getBuildTime returns the build time of the application
// This can be set using ldflags during build
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.BuildTime=2023-01-01T00:00:00Z"
var BuildTime = "unknown"
func getBuildTime() string {
return BuildTime
}
// getCommit returns the commit hash of the application
// This can be set using ldflags during build
// Example: go build -ldflags "-X github.com/starfleetcptn/gomft/components.Commit=abcdef123456"
var Commit = "unknown"
func getCommit() string {
return Commit
}
+25 -1
View File
@@ -3,8 +3,20 @@ package components
import (
"context"
"github.com/gin-gonic/gin"
"time"
"fmt"
)
// AppVersion will be set at build time using ldflags
// Example build command:
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
var AppVersion = "dev"
// GetReleaseURL returns the URL to the specific GitHub release
func GetReleaseURL() templ.SafeURL {
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/v%s", AppVersion))
}
// CreateTemplateContext creates a new context with user information from Gin's context
func CreateTemplateContext(c *gin.Context) context.Context {
ctx := context.Background()
@@ -437,6 +449,18 @@ templ LayoutWithContext(title string, ctx context.Context) {
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
GoMFT &copy; { getCurrentYear() } | Secure File Transfer Solution
</p>
<div class="flex justify-center items-center space-x-4 mt-1">
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fab fa-github mr-1"></i>GitHub
</a>
</p>
<p class="text-xs text-secondary-400 dark:text-secondary-500">
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
</a>
</p>
</div>
</div>
</footer>
@@ -544,5 +568,5 @@ func getUserEmail(ctx context.Context) string {
// Helper function to get current year
func getCurrentYear() string {
return "2025"
return time.Now().Format("2006")
}
+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>
}
}
+12
View File
@@ -3,6 +3,15 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
# Set the UID/GID to match your host user for better file permissions
# Default is 1000:1000 if not specified
UID: ${UID:-1000}
GID: ${GID:-1000}
# Version information
VERSION: ${VERSION:-dev}
BUILD_TIME: ${BUILD_TIME:-unknown}
COMMIT: ${COMMIT:-unknown}
container_name: gomft
restart: unless-stopped
ports:
@@ -22,6 +31,9 @@ services:
# - LOG_LEVEL=info
networks:
- gomft-network
# For non-root installs, the container needs to run with the same UID
# as the host user to access mounted volumes properly
user: ${UID:-1000}:${GID:-1000}
networks:
gomft-network:
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
set -e
# Default username
USERNAME=${USERNAME:-gomft}
# If PUID/PGID env vars are set, update the user's UID/GID
if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
echo "🔒 Updating user ${USERNAME} with UID:GID = ${PUID}:${PGID}"
# Make sure we have directories to work with
mkdir -p /app/data /app/backups
# Check if we're on Alpine (busybox)
if grep -q "Alpine" /etc/os-release 2>/dev/null; then
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
# Update group ID first
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
echo "Updating GID to ${PGID}..."
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
fi
# Update user ID
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
echo "Updating UID to ${PUID}..."
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
fi
else
echo "Non-Alpine system, using standard user management..."
# Handle user/group changes with error recovery
{
# First remove the user (since user has the group as primary group)
if getent passwd ${USERNAME} > /dev/null; then
echo "Removing existing user ${USERNAME}"
userdel ${USERNAME} 2>/dev/null || true
fi
# Wait a moment for system to clean up user
sleep 1
# Then remove the group
if getent group ${USERNAME} > /dev/null; then
echo "Removing existing group ${USERNAME}"
groupdel ${USERNAME} 2>/dev/null || true
fi
# Recreate group and user in the correct order
echo "Creating group ${USERNAME} with GID ${PGID}"
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
echo "Creating user ${USERNAME} with UID ${PUID}"
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
} || {
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
}
fi
# Fix ownership of app directories
echo "Setting ownership of app directories"
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
# Ensure .env file exists and has correct permissions
if [ -f /app/.env ]; then
echo "Found .env file, setting permissions..."
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
else
echo "No .env file found, creating empty one..."
touch /app/.env
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
fi
# Run the application as the specified user
echo "Starting application as user ${USERNAME}"
if command -v su-exec >/dev/null 2>&1; then
exec su-exec ${USERNAME} "$@"
elif command -v gosu >/dev/null 2>&1; then
exec gosu ${USERNAME} "$@"
else
exec su -m ${USERNAME} -c "$*"
fi
else
# Run as the predefined user (set during build)
echo "Starting application with predefined user"
exec "$@"
fi
+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
}
+20 -2
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
@@ -33,8 +34,25 @@ func InitialSchema() *gormigrate.Migration {
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"))
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
@@ -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 {
+98
View File
@@ -0,0 +1,98 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"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)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// 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)
}
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
// "github.com/starfleetcptn/gomft/internal/api"
"github.com/starfleetcptn/gomft/components"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/scheduler"
@@ -27,7 +28,7 @@ func main() {
gin.SetMode(gin.ReleaseMode)
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Starting GoMFT server...")
log.Printf("Starting GoMFT server version %s...", components.AppVersion)
// Initialize configuration
cfg, err := config.Load()