Add 2FA authentification.
This commit is contained in:
120
app.py
120
app.py
@@ -192,13 +192,19 @@ def login():
|
||||
flash('Your account has been disabled. Please contact an administrator.', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
# Continue with normal login process
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['is_admin'] = user.is_admin
|
||||
# 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
|
||||
session['is_admin'] = user.is_admin
|
||||
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(url_for('home'))
|
||||
flash('Login successful!', 'success')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
db.session.commit()
|
||||
print("Marked existing admin user as verified")
|
||||
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("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"""
|
||||
|
||||
32
models.py
32
models.py
@@ -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}>'
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
266
templates/setup_2fa.html
Normal 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
182
templates/verify_2fa.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user