Add 2FA authentification.

This commit is contained in:
Jens Luedicke
2025-06-29 15:41:55 +02:00
parent 6ebe575c4c
commit 0964a2177a
7 changed files with 768 additions and 13 deletions

108
app.py
View File

@@ -192,6 +192,12 @@ def login():
flash('Your account has been disabled. Please contact an administrator.', 'error')
return render_template('login.html')
# Check if 2FA is enabled
if user.two_factor_enabled:
# Store user ID for 2FA verification
session['2fa_user_id'] = user.id
return redirect(url_for('verify_2fa'))
else:
# Continue with normal login process
session['user_id'] = user.id
session['username'] = user.username
@@ -552,6 +558,108 @@ def profile():
return render_template('profile.html', title='My Profile', user=user)
@app.route('/2fa/setup', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
# Verify the TOTP code before enabling 2FA
totp_code = request.form.get('totp_code')
if not totp_code:
flash('Please enter the verification code from your authenticator app.', 'error')
return redirect(url_for('setup_2fa'))
try:
if g.user.verify_2fa_token(totp_code, allow_setup=True):
g.user.two_factor_enabled = True
db.session.commit()
flash('Two-factor authentication has been successfully enabled!', 'success')
return redirect(url_for('profile'))
else:
flash('Invalid verification code. Please make sure your device time is synchronized and try again.', 'error')
return redirect(url_for('setup_2fa'))
except Exception as e:
logger.error(f"2FA setup error: {str(e)}")
flash('An error occurred during 2FA setup. Please try again.', 'error')
return redirect(url_for('setup_2fa'))
# GET request - show setup page
if g.user.two_factor_enabled:
flash('Two-factor authentication is already enabled.', 'info')
return redirect(url_for('profile'))
# Generate secret if not exists
if not g.user.two_factor_secret:
g.user.generate_2fa_secret()
db.session.commit()
# Generate QR code
import qrcode
import io
import base64
qr_uri = g.user.get_2fa_uri()
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(qr_uri)
qr.make(fit=True)
# Create QR code image
qr_img = qr.make_image(fill_color="black", back_color="white")
img_buffer = io.BytesIO()
qr_img.save(img_buffer, format='PNG')
img_buffer.seek(0)
qr_code_b64 = base64.b64encode(img_buffer.getvalue()).decode()
return render_template('setup_2fa.html',
title='Setup Two-Factor Authentication',
secret=g.user.two_factor_secret,
qr_code=qr_code_b64)
@app.route('/2fa/disable', methods=['POST'])
@login_required
def disable_2fa():
password = request.form.get('password')
if not password or not g.user.check_password(password):
flash('Please enter your correct password to disable 2FA.', 'error')
return redirect(url_for('profile'))
g.user.two_factor_enabled = False
g.user.two_factor_secret = None
db.session.commit()
flash('Two-factor authentication has been disabled.', 'success')
return redirect(url_for('profile'))
@app.route('/2fa/verify', methods=['GET', 'POST'])
def verify_2fa():
# Check if user is in 2FA verification state
user_id = session.get('2fa_user_id')
if not user_id:
return redirect(url_for('login'))
user = User.query.get(user_id)
if not user or not user.two_factor_enabled:
session.pop('2fa_user_id', None)
return redirect(url_for('login'))
if request.method == 'POST':
totp_code = request.form.get('totp_code')
if user.verify_2fa_token(totp_code):
# Complete login process
session.pop('2fa_user_id', None)
session['user_id'] = user.id
session['username'] = user.username
session['is_admin'] = user.is_admin
flash('Login successful!', 'success')
return redirect(url_for('home'))
else:
flash('Invalid verification code. Please try again.', 'error')
return render_template('verify_2fa.html', title='Two-Factor Authentication')
@app.route('/about')
@login_required
def about():

View File

@@ -1,7 +1,7 @@
from app import app, db
import sqlite3
import os
from models import User, TimeEntry, WorkConfig, SystemSettings
from models import User, TimeEntry, WorkConfig, SystemSettings, Team, Role
from werkzeug.security import generate_password_hash
from datetime import datetime
@@ -107,6 +107,38 @@ def migrate_database():
print("Adding is_blocked column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN is_blocked BOOLEAN DEFAULT 0")
# Add role column to user table if it doesn't exist
if 'role' not in user_columns:
print("Adding role column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN role VARCHAR(50) DEFAULT 'Team Member'")
# Add team_id column to user table if it doesn't exist
if 'team_id' not in user_columns:
print("Adding team_id column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN team_id INTEGER")
# Add 2FA columns to user table if they don't exist
if 'two_factor_enabled' not in user_columns:
print("Adding two_factor_enabled column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0")
if 'two_factor_secret' not in user_columns:
print("Adding two_factor_secret column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)")
# Check if the team table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'")
if not cursor.fetchone():
print("Creating team table...")
cursor.execute("""
CREATE TABLE team (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) UNIQUE NOT NULL,
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Check if the system_settings table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_settings'")
if not cursor.fetchone():
@@ -140,7 +172,9 @@ def migrate_database():
username='admin',
email='admin@timetrack.local',
is_admin=True,
is_verified=True # Admin is automatically verified
is_verified=True, # Admin is automatically verified
role=Role.ADMIN,
two_factor_enabled=False
)
admin.set_password('admin') # Default password, should be changed
db.session.add(admin)
@@ -148,11 +182,15 @@ def migrate_database():
print("Created admin user with username 'admin' and password 'admin'")
print("Please change the admin password after first login!")
else:
# Make sure existing admin user is verified
# Make sure existing admin user is verified and has correct role
if not hasattr(admin, 'is_verified') or not admin.is_verified:
admin.is_verified = True
if not hasattr(admin, 'role') or admin.role is None:
admin.role = Role.ADMIN
if not hasattr(admin, 'two_factor_enabled') or admin.two_factor_enabled is None:
admin.two_factor_enabled = False
db.session.commit()
print("Marked existing admin user as verified")
print("Updated existing admin user with new fields")
# Update existing time entries to associate with admin user
orphan_entries = TimeEntry.query.filter_by(user_id=None).all()
@@ -169,10 +207,28 @@ def migrate_database():
for user in existing_users:
user.is_verified = True
# Update existing users with default role and 2FA settings
users_to_update = User.query.all()
updated_count = 0
for user in users_to_update:
updated = False
if not hasattr(user, 'role') or user.role is None:
if user.is_admin:
user.role = Role.ADMIN
else:
user.role = Role.TEAM_MEMBER
updated = True
if not hasattr(user, 'two_factor_enabled') or user.two_factor_enabled is None:
user.two_factor_enabled = False
updated = True
if updated:
updated_count += 1
db.session.commit()
print(f"Associated {len(orphan_entries)} existing time entries with admin user")
print(f"Associated {len(orphan_configs)} existing work configs with admin user")
print(f"Marked {len(existing_users)} existing users as verified")
print(f"Updated {updated_count} users with default role and 2FA settings")
def init_system_settings():
"""Initialize system settings with default values if they don't exist"""

View File

@@ -47,6 +47,10 @@ class User(db.Model):
role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
# Two-Factor Authentication fields
two_factor_enabled = db.Column(db.Boolean, default=False)
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
@@ -72,6 +76,34 @@ class User(db.Model):
return True
return False
def generate_2fa_secret(self):
"""Generate a new 2FA secret"""
import pyotp
self.two_factor_secret = pyotp.random_base32()
return self.two_factor_secret
def get_2fa_uri(self):
"""Get the provisioning URI for QR code generation"""
if not self.two_factor_secret:
return None
import pyotp
totp = pyotp.TOTP(self.two_factor_secret)
return totp.provisioning_uri(
name=self.email,
issuer_name="TimeTrack"
)
def verify_2fa_token(self, token, allow_setup=False):
"""Verify a 2FA token"""
if not self.two_factor_secret:
return False
# During setup, allow verification even if 2FA isn't enabled yet
if not allow_setup and not self.two_factor_enabled:
return False
import pyotp
totp = pyotp.TOTP(self.two_factor_secret)
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
def __repr__(self):
return f'<User {self.username}>'

View File

@@ -7,3 +7,5 @@ click==8.0.1
Flask-SQLAlchemy==2.5.1
SQLAlchemy==1.4.23
python-dotenv==0.19.0
pyotp==2.6.0
qrcode[pil]==7.3.1

View File

@@ -16,6 +16,13 @@
<p><strong>Username:</strong> {{ user.username }}</p>
<p><strong>Account Type:</strong> {% if user.is_admin %}Administrator{% else %}User{% endif %}</p>
<p><strong>Member Since:</strong> {{ user.created_at.strftime('%Y-%m-%d') }}</p>
<p><strong>Two-Factor Authentication:</strong>
{% if user.two_factor_enabled %}
<span class="status enabled">✅ Enabled</span>
{% else %}
<span class="status disabled">❌ Disabled</span>
{% endif %}
</p>
</div>
<h2>Update Profile</h2>
@@ -45,5 +52,107 @@
<button type="submit" class="btn btn-primary">Update Profile</button>
</div>
</form>
<div class="security-section">
<h2>Security Settings</h2>
<div class="security-card">
<h3>Two-Factor Authentication</h3>
{% if user.two_factor_enabled %}
<p>Two-factor authentication is <strong>enabled</strong> for your account. This adds an extra layer of security by requiring a code from your authenticator app when logging in.</p>
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
<div class="form-group">
<label for="password_disable">Enter your password to disable 2FA:</label>
<input type="password" id="password_disable" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
</form>
{% else %}
<p>Two-factor authentication is <strong>not enabled</strong> for your account. We strongly recommend enabling it to protect your account.</p>
<p>With 2FA enabled, you'll need both your password and a code from your phone to log in.</p>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable Two-Factor Authentication</a>
{% endif %}
</div>
</div>
</div>
<style>
.status.enabled {
color: #28a745;
font-weight: bold;
}
.status.disabled {
color: #dc3545;
font-weight: bold;
}
.security-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #dee2e6;
}
.security-card {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1rem 0;
}
.security-card h3 {
color: #007bff;
margin-bottom: 1rem;
}
.disable-2fa-form {
margin-top: 1rem;
padding: 1rem;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 0.25rem;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin: 0.5rem 0;
border: none;
border-radius: 0.25rem;
text-decoration: none;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
</style>
{% endblock %}

266
templates/setup_2fa.html Normal file
View File

@@ -0,0 +1,266 @@
{% extends "layout.html" %}
{% block content %}
<div class="setup-2fa-container">
<h1>Setup Two-Factor Authentication</h1>
<div class="setup-steps">
<div class="step">
<h2>Step 1: Install an Authenticator App</h2>
<p>Download and install an authenticator app on your mobile device:</p>
<ul>
<li><strong>Google Authenticator</strong> (iOS/Android)</li>
<li><strong>Microsoft Authenticator</strong> (iOS/Android)</li>
<li><strong>Authy</strong> (iOS/Android/Desktop)</li>
<li><strong>1Password</strong> (Premium feature)</li>
</ul>
</div>
<div class="step">
<h2>Step 2: Scan QR Code or Enter Secret</h2>
<div class="qr-section">
<div class="qr-code">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code">
</div>
<div class="manual-entry">
<h3>Can't scan? Enter this code manually:</h3>
<div class="secret-code">{{ secret }}</div>
<p><small>Account: {{ g.user.email }}<br>Issuer: TimeTrack</small></p>
</div>
</div>
</div>
<div class="step">
<h2>Step 3: Verify Setup</h2>
<p>Enter the 6-digit code from your authenticator app to complete setup:</p>
<form method="POST" class="verification-form">
<div class="form-group">
<label for="totp_code">Verification Code:</label>
<input type="text" id="totp_code" name="totp_code"
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
required autocomplete="off" autofocus>
<small>Enter the 6-digit code from your authenticator app</small>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Enable Two-Factor Authentication</button>
<a href="{{ url_for('profile') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<div class="security-notice">
<h3>🔐 Security Notice</h3>
<p><strong>Important:</strong> Once enabled, you'll need your authenticator app to log in. Make sure to:</p>
<ul>
<li>Keep your authenticator app secure and backed up</li>
<li>Store the secret code in a safe place as a backup</li>
<li>Remember your password - you'll need both your password and 2FA code to log in</li>
</ul>
</div>
</div>
<style>
.setup-2fa-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.setup-steps {
margin: 2rem 0;
}
.step {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1.5rem 0;
}
.step h2 {
color: #007bff;
margin-bottom: 1rem;
}
.step ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.step li {
margin: 0.5rem 0;
}
.qr-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: center;
margin: 1rem 0;
}
.qr-code {
text-align: center;
}
.qr-code img {
max-width: 200px;
height: auto;
border: 2px solid #007bff;
border-radius: 0.5rem;
padding: 1rem;
background: white;
}
.manual-entry {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #dee2e6;
}
.secret-code {
background: #f1f3f4;
padding: 0.75rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 1.1rem;
word-break: break-all;
margin: 0.5rem 0;
border: 1px solid #dee2e6;
}
.verification-form {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid #dee2e6;
margin: 1rem 0;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="text"] {
width: 200px;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
font-size: 1.2rem;
text-align: center;
letter-spacing: 0.2em;
font-family: 'Courier New', monospace;
}
.form-group small {
display: block;
color: #6c757d;
margin-top: 0.25rem;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.25rem;
text-decoration: none;
display: inline-block;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
.security-notice {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 2rem 0;
}
.security-notice h3 {
color: #856404;
margin-bottom: 1rem;
}
.security-notice ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.security-notice li {
margin: 0.5rem 0;
color: #856404;
}
@media (max-width: 768px) {
.qr-section {
grid-template-columns: 1fr;
text-align: center;
}
.button-group {
flex-direction: column;
}
.setup-2fa-container {
padding: 1rem;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('totp_code');
// Auto-format input to digits only
input.addEventListener('input', function(e) {
e.target.value = e.target.value.replace(/\D/g, '');
});
// Auto-submit when 6 digits are entered
input.addEventListener('input', function(e) {
if (e.target.value.length === 6) {
// Small delay to let user see the complete code
setTimeout(function() {
document.querySelector('.verification-form').submit();
}, 500);
}
});
});
</script>
{% endblock %}

182
templates/verify_2fa.html Normal file
View File

@@ -0,0 +1,182 @@
{% extends "layout.html" %}
{% block content %}
<div class="verify-2fa-container">
<div class="verification-card">
<h1>Two-Factor Authentication</h1>
<p class="instruction">Please enter the 6-digit code from your authenticator app to complete login.</p>
<form method="POST" class="verification-form">
<div class="form-group">
<label for="totp_code">Verification Code:</label>
<input type="text" id="totp_code" name="totp_code"
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
required autocomplete="off" autofocus>
<small>Enter the 6-digit code from your authenticator app</small>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Verify & Login</button>
</div>
</form>
<div class="help-section">
<p><small>Having trouble? Make sure your device's time is synchronized and try a new code.</small></p>
<p><small><a href="{{ url_for('login') }}">← Back to Login</a></small></p>
</div>
</div>
</div>
<style>
.verify-2fa-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
padding: 2rem;
}
.verification-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 2rem;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.verification-card h1 {
text-align: center;
color: #007bff;
margin-bottom: 1rem;
}
.instruction {
text-align: center;
color: #6c757d;
margin-bottom: 2rem;
}
.verification-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: center;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="text"] {
width: 200px;
padding: 1rem;
border: 2px solid #dee2e6;
border-radius: 0.5rem;
font-size: 1.5rem;
text-align: center;
letter-spacing: 0.3em;
font-family: 'Courier New', monospace;
transition: border-color 0.2s;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-group small {
display: block;
color: #6c757d;
margin-top: 0.5rem;
}
.button-group {
text-align: center;
margin-bottom: 1.5rem;
}
.btn {
padding: 0.75rem 2rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
width: 100%;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.help-section {
text-align: center;
border-top: 1px solid #dee2e6;
padding-top: 1rem;
}
.help-section p {
margin: 0.5rem 0;
}
.help-section a {
color: #007bff;
text-decoration: none;
}
.help-section a:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.verify-2fa-container {
padding: 1rem;
}
.verification-card {
padding: 1.5rem;
}
.form-group input[type="text"] {
width: 100%;
max-width: 200px;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('totp_code');
// Auto-format input to digits only
input.addEventListener('input', function(e) {
e.target.value = e.target.value.replace(/\D/g, '');
});
// Auto-submit when 6 digits are entered
input.addEventListener('input', function(e) {
if (e.target.value.length === 6) {
// Small delay to let user see the complete code
setTimeout(function() {
document.querySelector('.verification-form').submit();
}, 1000);
}
});
// Focus on input when page loads
input.focus();
});
</script>
{% endblock %}