fina/backup/fina-1/app/routes/auth.py

361 lines
13 KiB
Python
Raw Permalink Normal View History

2025-12-26 00:52:56 +00:00
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, send_file, make_response
from flask_login import login_user, logout_user, login_required, current_user
from app import db, bcrypt
from app.models import User
import pyotp
import qrcode
import io
import base64
import secrets
import json
from datetime import datetime
bp = Blueprint('auth', __name__, url_prefix='/auth')
def generate_backup_codes(count=10):
"""Generate backup codes for 2FA"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = ''.join(secrets.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(8))
# Format as XXXX-XXXX for readability
formatted_code = f"{code[:4]}-{code[4:]}"
codes.append(formatted_code)
return codes
def hash_backup_codes(codes):
"""Hash backup codes for secure storage"""
return [bcrypt.generate_password_hash(code).decode('utf-8') for code in codes]
def verify_backup_code(user, code):
"""Verify a backup code and mark it as used"""
if not user.backup_codes:
return False
stored_codes = json.loads(user.backup_codes)
for i, hashed_code in enumerate(stored_codes):
if bcrypt.check_password_hash(hashed_code, code):
# Remove used code
stored_codes.pop(i)
user.backup_codes = json.dumps(stored_codes)
db.session.commit()
return True
return False
@bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
password = data.get('password')
two_factor_code = data.get('two_factor_code')
remember = data.get('remember', False)
# Accept both username and email
user = User.query.filter((User.username == username) | (User.email == username)).first()
if user and bcrypt.check_password_hash(user.password_hash, password):
# Check 2FA if enabled
if user.two_factor_enabled:
if not two_factor_code:
if request.is_json:
return {'success': False, 'requires_2fa': True}, 200
session['pending_user_id'] = user.id
return render_template('auth/two_factor.html')
# Try TOTP code first
totp = pyotp.TOTP(user.totp_secret)
is_valid = totp.verify(two_factor_code)
# If TOTP fails, try backup code (format: XXXX-XXXX or XXXXXXXX)
if not is_valid:
is_valid = verify_backup_code(user, two_factor_code)
if not is_valid:
if request.is_json:
return {'success': False, 'message': 'Invalid 2FA code'}, 401
flash('Invalid 2FA code', 'error')
return render_template('auth/login.html')
login_user(user, remember=remember)
session.permanent = remember
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
next_page = request.args.get('next')
return redirect(next_page if next_page else url_for('main.dashboard'))
if request.is_json:
return {'success': False, 'message': 'Invalid username or password'}, 401
flash('Invalid username or password', 'error')
return render_template('auth/login.html')
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = data.get('username')
email = data.get('email')
password = data.get('password')
language = data.get('language', 'en')
currency = data.get('currency', 'USD')
# Check if user exists
if User.query.filter_by(email=email).first():
if request.is_json:
return {'success': False, 'message': 'Email already registered'}, 400
flash('Email already registered', 'error')
return render_template('auth/register.html')
if User.query.filter_by(username=username).first():
if request.is_json:
return {'success': False, 'message': 'Username already taken'}, 400
flash('Username already taken', 'error')
return render_template('auth/register.html')
# Check if this is the first user (make them admin)
is_first_user = User.query.count() == 0
# Create user
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
user = User(
username=username,
email=email,
password_hash=password_hash,
is_admin=is_first_user,
language=language,
currency=currency
)
db.session.add(user)
db.session.commit()
# Create default categories
from app.utils import create_default_categories
create_default_categories(user.id)
login_user(user)
if request.is_json:
return {'success': True, 'redirect': url_for('main.dashboard')}
flash('Registration successful!', 'success')
return redirect(url_for('main.dashboard'))
return render_template('auth/register.html')
@bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('auth.login'))
@bp.route('/setup-2fa', methods=['GET', 'POST'])
@login_required
def setup_2fa():
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
code = data.get('code')
if not current_user.totp_secret:
secret = pyotp.random_base32()
current_user.totp_secret = secret
totp = pyotp.TOTP(current_user.totp_secret)
if totp.verify(code):
# Generate backup codes
backup_codes_plain = generate_backup_codes(10)
backup_codes_hashed = hash_backup_codes(backup_codes_plain)
current_user.two_factor_enabled = True
current_user.backup_codes = json.dumps(backup_codes_hashed)
db.session.commit()
# Store plain backup codes in session for display
session['backup_codes'] = backup_codes_plain
if request.is_json:
return {'success': True, 'message': '2FA enabled successfully', 'backup_codes': backup_codes_plain}
flash('2FA enabled successfully', 'success')
return redirect(url_for('auth.show_backup_codes'))
if request.is_json:
return {'success': False, 'message': 'Invalid code'}, 400
flash('Invalid code', 'error')
# Generate QR code
if not current_user.totp_secret:
current_user.totp_secret = pyotp.random_base32()
db.session.commit()
totp = pyotp.TOTP(current_user.totp_secret)
provisioning_uri = totp.provisioning_uri(
name=current_user.email,
issuer_name='FINA'
)
# Generate QR code image
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_code_base64 = base64.b64encode(buf.getvalue()).decode()
return render_template('auth/setup_2fa.html',
qr_code=qr_code_base64,
secret=current_user.totp_secret)
@bp.route('/backup-codes', methods=['GET'])
@login_required
def show_backup_codes():
"""Display backup codes after 2FA setup"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
return render_template('auth/backup_codes.html',
backup_codes=backup_codes,
username=current_user.username)
@bp.route('/backup-codes/download', methods=['GET'])
@login_required
def download_backup_codes_pdf():
"""Download backup codes as PDF"""
backup_codes = session.get('backup_codes', [])
if not backup_codes:
flash('No backup codes available', 'error')
return redirect(url_for('main.settings'))
try:
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib import colors
# Create PDF in memory
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
# Title
c.setFont("Helvetica-Bold", 24)
c.drawCentredString(width/2, height - 1*inch, "FINA")
c.setFont("Helvetica-Bold", 18)
c.drawCentredString(width/2, height - 1.5*inch, "Two-Factor Authentication")
c.drawCentredString(width/2, height - 1.9*inch, "Backup Codes")
# User info
c.setFont("Helvetica", 12)
c.drawString(1*inch, height - 2.5*inch, f"User: {current_user.username}")
c.drawString(1*inch, height - 2.8*inch, f"Email: {current_user.email}")
c.drawString(1*inch, height - 3.1*inch, f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
# Warning message
c.setFillColorRGB(0.8, 0.2, 0.2)
c.setFont("Helvetica-Bold", 11)
c.drawString(1*inch, height - 3.7*inch, "IMPORTANT: Store these codes in a secure location!")
c.setFillColorRGB(0, 0, 0)
c.setFont("Helvetica", 10)
c.drawString(1*inch, height - 4.0*inch, "Each code can only be used once. Use them if you lose access to your authenticator app.")
# Backup codes in two columns
c.setFont("Courier-Bold", 14)
y_position = height - 4.8*inch
x_left = 1.5*inch
x_right = 4.5*inch
for i, code in enumerate(backup_codes):
if i % 2 == 0:
c.drawString(x_left, y_position, f"{i+1:2d}. {code}")
else:
c.drawString(x_right, y_position, f"{i+1:2d}. {code}")
y_position -= 0.4*inch
# Footer
c.setFont("Helvetica", 8)
c.setFillColorRGB(0.5, 0.5, 0.5)
c.drawCentredString(width/2, 0.5*inch, "Keep this document secure and do not share these codes with anyone.")
c.save()
buffer.seek(0)
# Clear backup codes from session after download
session.pop('backup_codes', None)
# Create response with PDF
response = make_response(buffer.getvalue())
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.pdf'
return response
except ImportError:
# If reportlab is not installed, return codes as text file
text_content = f"FINA - Two-Factor Authentication Backup Codes\n\n"
text_content += f"User: {current_user.username}\n"
text_content += f"Email: {current_user.email}\n"
text_content += f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}\n\n"
text_content += "IMPORTANT: Store these codes in a secure location!\n"
text_content += "Each code can only be used once.\n\n"
text_content += "Backup Codes:\n"
text_content += "-" * 40 + "\n"
for i, code in enumerate(backup_codes, 1):
text_content += f"{i:2d}. {code}\n"
text_content += "-" * 40 + "\n"
text_content += "\nKeep this document secure and do not share these codes with anyone."
# Clear backup codes from session
session.pop('backup_codes', None)
response = make_response(text_content)
response.headers['Content-Type'] = 'text/plain'
response.headers['Content-Disposition'] = f'attachment; filename=FINA_BackupCodes_{current_user.username}_{datetime.utcnow().strftime("%Y%m%d")}.txt'
return response
@bp.route('/disable-2fa', methods=['POST'])
@login_required
def disable_2fa():
current_user.two_factor_enabled = False
current_user.backup_codes = None
db.session.commit()
if request.is_json:
return {'success': True, 'message': '2FA disabled'}
flash('2FA disabled', 'success')
return redirect(url_for('main.settings'))