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') flash('Your account has been disabled. Please contact an administrator.', 'error')
return render_template('login.html') 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 # Continue with normal login process
session['user_id'] = user.id session['user_id'] = user.id
session['username'] = user.username session['username'] = user.username
@@ -552,6 +558,108 @@ def profile():
return render_template('profile.html', title='My Profile', user=user) 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') @app.route('/about')
@login_required @login_required
def about(): def about():

View File

@@ -1,7 +1,7 @@
from app import app, db from app import app, db
import sqlite3 import sqlite3
import os 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 werkzeug.security import generate_password_hash
from datetime import datetime from datetime import datetime
@@ -107,6 +107,38 @@ def migrate_database():
print("Adding is_blocked column to user table...") print("Adding is_blocked column to user table...")
cursor.execute("ALTER TABLE user ADD COLUMN is_blocked BOOLEAN DEFAULT 0") 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 # Check if the system_settings table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_settings'") cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_settings'")
if not cursor.fetchone(): if not cursor.fetchone():
@@ -140,7 +172,9 @@ def migrate_database():
username='admin', username='admin',
email='admin@timetrack.local', email='admin@timetrack.local',
is_admin=True, 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 admin.set_password('admin') # Default password, should be changed
db.session.add(admin) db.session.add(admin)
@@ -148,11 +182,15 @@ def migrate_database():
print("Created admin user with username 'admin' and password 'admin'") print("Created admin user with username 'admin' and password 'admin'")
print("Please change the admin password after first login!") print("Please change the admin password after first login!")
else: 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: if not hasattr(admin, 'is_verified') or not admin.is_verified:
admin.is_verified = True 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() 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 # Update existing time entries to associate with admin user
orphan_entries = TimeEntry.query.filter_by(user_id=None).all() orphan_entries = TimeEntry.query.filter_by(user_id=None).all()
@@ -169,10 +207,28 @@ def migrate_database():
for user in existing_users: for user in existing_users:
user.is_verified = True 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() db.session.commit()
print(f"Associated {len(orphan_entries)} existing time entries with admin user") 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"Associated {len(orphan_configs)} existing work configs with admin user")
print(f"Marked {len(existing_users)} existing users as verified") 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(): def init_system_settings():
"""Initialize system settings with default values if they don't exist""" """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) role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) 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 # Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy=True) time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False) work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
@@ -72,6 +76,34 @@ class User(db.Model):
return True return True
return False 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): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'

View File

@@ -7,3 +7,5 @@ click==8.0.1
Flask-SQLAlchemy==2.5.1 Flask-SQLAlchemy==2.5.1
SQLAlchemy==1.4.23 SQLAlchemy==1.4.23
python-dotenv==0.19.0 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>Username:</strong> {{ user.username }}</p>
<p><strong>Account Type:</strong> {% if user.is_admin %}Administrator{% else %}User{% endif %}</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>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> </div>
<h2>Update Profile</h2> <h2>Update Profile</h2>
@@ -45,5 +52,107 @@
<button type="submit" class="btn btn-primary">Update Profile</button> <button type="submit" class="btn btn-primary">Update Profile</button>
</div> </div>
</form> </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> </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 %} {% 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 %}