feat: Add security features - 2FA, audit log, alerting

Security enhancements:
- Two-Factor Authentication (TOTP) for all users
  - Enable/disable 2FA in settings
  - Backup codes for recovery
  - Login flow with 2FA verification
- Audit log for admin actions
  - Track all sensitive operations
  - IP address and user agent logging
- Security alerts system
  - Alert types: brute_force, honeypot_hit, account_locked, geo_blocked
  - Email notifications for high/critical alerts
  - Dashboard for alert management
- Admin security dashboard (/admin/security)
  - View/acknowledge/resolve alerts
  - Unlock locked accounts
  - 2FA status overview

New files:
- security_service.py: Security utilities
- templates/auth/verify_2fa.html
- templates/auth/2fa_settings.html
- templates/auth/2fa_setup.html
- templates/auth/2fa_backup_codes.html
- templates/admin/security_dashboard.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-14 21:23:27 +01:00
parent 7226e098f6
commit 0dba52e9c4
8 changed files with 2187 additions and 1 deletions

355
app.py
View File

@ -134,7 +134,9 @@ from database import (
PageView,
UserClick,
AnalyticsDaily,
PopularPagesDaily
PopularPagesDaily,
AuditLog,
SecurityAlert
)
# Import services
@ -144,6 +146,19 @@ from search_service import search_companies
import krs_api_service
from file_upload_service import FileUploadService
# Security service for audit log, alerting, GeoIP, 2FA
try:
from security_service import (
log_audit, create_security_alert, get_client_ip,
is_ip_allowed, geoip_check, init_security_service,
generate_totp_secret, get_totp_uri, verify_totp,
generate_backup_codes, verify_backup_code, requires_2fa
)
SECURITY_SERVICE_AVAILABLE = True
except ImportError as e:
SECURITY_SERVICE_AVAILABLE = False
logger.warning(f"Security service not available: {e}")
# News service for fetching company news
try:
from news_service import NewsService, get_news_service, init_news_service
@ -3929,6 +3944,20 @@ def login():
# Reset failed attempts on successful login
user.failed_login_attempts = 0
user.locked_until = None
# Check if user has 2FA enabled
if user.totp_enabled and SECURITY_SERVICE_AVAILABLE:
# Store pending login in session for 2FA verification
session['2fa_pending_user_id'] = user.id
session['2fa_remember'] = remember
next_page = request.args.get('next')
if next_page and next_page.startswith('/'):
session['2fa_next'] = next_page
db.commit()
logger.info(f"2FA required for user: {email}")
return redirect(url_for('verify_2fa'))
# No 2FA - login directly
login_user(user, remember=remember)
user.last_login = datetime.now()
db.commit()
@ -3956,11 +3985,178 @@ def login():
@login_required
def logout():
"""User logout"""
# Clear 2FA session flag
session.pop('2fa_verified', None)
logout_user()
flash('Wylogowano pomyślnie.', 'success')
return redirect(url_for('index'))
# ============================================================
# TWO-FACTOR AUTHENTICATION
# ============================================================
@app.route('/verify-2fa', methods=['GET', 'POST'])
@limiter.limit("10 per minute")
def verify_2fa():
"""Verify 2FA code during login"""
# Check if there's a pending 2FA login
pending_user_id = session.get('2fa_pending_user_id')
if not pending_user_id:
flash('Sesja wygasła. Zaloguj się ponownie.', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
code = request.form.get('code', '').strip()
use_backup = request.form.get('use_backup', False)
if not code:
flash('Wprowadź kod weryfikacyjny.', 'error')
return render_template('auth/verify_2fa.html')
db = SessionLocal()
try:
user = db.query(User).get(pending_user_id)
if not user:
session.pop('2fa_pending_user_id', None)
flash('Użytkownik nie istnieje.', 'error')
return redirect(url_for('login'))
# Verify code
if SECURITY_SERVICE_AVAILABLE:
if use_backup:
valid = verify_backup_code(user, code, db)
else:
valid = verify_totp(user, code)
else:
valid = False
if valid:
# Clear pending login and log in
session.pop('2fa_pending_user_id', None)
remember = session.pop('2fa_remember', False)
next_page = session.pop('2fa_next', None)
login_user(user, remember=remember)
session['2fa_verified'] = True
user.last_login = datetime.now()
db.commit()
logger.info(f"User logged in with 2FA: {user.email}")
flash('Zalogowano pomyślnie.', 'success')
return redirect(next_page or url_for('dashboard'))
else:
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
if client_ip and ',' in client_ip:
client_ip = client_ip.split(',')[0].strip()
security_logger.warning(f"INVALID_2FA ip={client_ip} user_id={pending_user_id}")
flash('Nieprawidłowy kod weryfikacyjny.', 'error')
except Exception as e:
logger.error(f"2FA verification error: {e}")
flash('Wystąpił błąd. Spróbuj ponownie.', 'error')
finally:
db.close()
return render_template('auth/verify_2fa.html')
@app.route('/settings/2fa', methods=['GET', 'POST'])
@login_required
def settings_2fa():
"""2FA settings - enable/disable"""
db = SessionLocal()
try:
user = db.query(User).get(current_user.id)
if request.method == 'POST':
action = request.form.get('action')
if action == 'setup':
# Generate new secret
if SECURITY_SERVICE_AVAILABLE:
secret = generate_totp_secret()
if secret:
user.totp_secret = secret
user.totp_enabled = False # Not enabled until verified
db.commit()
qr_uri = get_totp_uri(user)
return render_template('auth/2fa_setup.html',
qr_uri=qr_uri,
secret=secret)
flash('Błąd konfiguracji 2FA.', 'error')
elif action == 'verify_setup':
# Verify the setup code and enable 2FA
code = request.form.get('code', '').strip()
if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code):
user.totp_enabled = True
# Generate backup codes
backup_codes = generate_backup_codes(8)
user.totp_backup_codes = backup_codes
db.commit()
# Log audit
if SECURITY_SERVICE_AVAILABLE:
log_audit(db, '2fa.enabled', 'user', user.id, user.email)
db.commit()
logger.info(f"2FA enabled for user: {user.email}")
return render_template('auth/2fa_backup_codes.html',
backup_codes=backup_codes)
else:
flash('Nieprawidłowy kod. Spróbuj ponownie.', 'error')
qr_uri = get_totp_uri(user)
return render_template('auth/2fa_setup.html',
qr_uri=qr_uri,
secret=user.totp_secret)
elif action == 'disable':
# Require current code to disable
code = request.form.get('code', '').strip()
if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code):
user.totp_enabled = False
user.totp_secret = None
user.totp_backup_codes = None
db.commit()
# Log audit
if SECURITY_SERVICE_AVAILABLE:
log_audit(db, '2fa.disabled', 'user', user.id, user.email)
db.commit()
logger.info(f"2FA disabled for user: {user.email}")
flash('Uwierzytelnianie dwuskładnikowe zostało wyłączone.', 'success')
else:
flash('Nieprawidłowy kod. Nie można wyłączyć 2FA.', 'error')
elif action == 'regenerate_backup':
# Require current code to regenerate backup codes
code = request.form.get('code', '').strip()
if SECURITY_SERVICE_AVAILABLE and verify_totp(user, code):
backup_codes = generate_backup_codes(8)
user.totp_backup_codes = backup_codes
db.commit()
logger.info(f"Backup codes regenerated for user: {user.email}")
return render_template('auth/2fa_backup_codes.html',
backup_codes=backup_codes)
else:
flash('Nieprawidłowy kod. Nie można wygenerować kodów.', 'error')
return render_template('auth/2fa_settings.html',
totp_enabled=user.totp_enabled,
backup_codes_count=len(user.totp_backup_codes) if user.totp_backup_codes else 0)
except Exception as e:
logger.error(f"2FA settings error: {e}")
flash('Wystąpił błąd.', 'error')
return redirect(url_for('dashboard'))
finally:
db.close()
@app.route('/forgot-password', methods=['GET', 'POST'])
@limiter.limit("5 per hour")
def forgot_password():
@ -10329,6 +10525,163 @@ def internal_error(error):
return render_template('errors/500.html'), 500
# ============================================================
# ADMIN - SECURITY DASHBOARD
# ============================================================
@app.route('/admin/security')
@login_required
def admin_security():
"""Security dashboard - audit logs, alerts, GeoIP stats"""
if not current_user.is_admin:
flash('Brak uprawnień.', 'error')
return redirect(url_for('dashboard'))
db = SessionLocal()
try:
from sqlalchemy import func, desc
# Get recent audit logs
audit_logs = db.query(AuditLog).order_by(
desc(AuditLog.created_at)
).limit(50).all()
# Get security alerts
alerts = db.query(SecurityAlert).order_by(
desc(SecurityAlert.created_at)
).limit(50).all()
# Alert stats
new_alerts_count = db.query(SecurityAlert).filter(
SecurityAlert.status == 'new'
).count()
# Recent locked accounts
locked_accounts = db.query(User).filter(
User.locked_until > datetime.now()
).all()
# Users with 2FA enabled
users_with_2fa = db.query(User).filter(
User.totp_enabled == True
).count()
total_admins = db.query(User).filter(
User.is_admin == True
).count()
# Alert type breakdown
alert_breakdown = db.query(
SecurityAlert.alert_type,
func.count(SecurityAlert.id).label('count')
).group_by(SecurityAlert.alert_type).all()
stats = {
'new_alerts': new_alerts_count,
'locked_accounts': len(locked_accounts),
'users_with_2fa': users_with_2fa,
'total_admins': total_admins,
'alert_breakdown': {a.alert_type: a.count for a in alert_breakdown}
}
return render_template(
'admin/security_dashboard.html',
audit_logs=audit_logs,
alerts=alerts,
locked_accounts=locked_accounts,
stats=stats
)
finally:
db.close()
@app.route('/admin/security/alert/<int:alert_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_security_alert(alert_id):
"""Acknowledge a security alert"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
db = SessionLocal()
try:
alert = db.query(SecurityAlert).get(alert_id)
if not alert:
return jsonify({'success': False, 'error': 'Alert not found'}), 404
alert.status = 'acknowledged'
alert.acknowledged_by = current_user.id
alert.acknowledged_at = datetime.now()
# Log audit
if SECURITY_SERVICE_AVAILABLE:
log_audit(db, 'alert.acknowledge', 'security_alert', alert_id,
details={'alert_type': alert.alert_type})
db.commit()
return jsonify({'success': True})
finally:
db.close()
@app.route('/admin/security/alert/<int:alert_id>/resolve', methods=['POST'])
@login_required
def resolve_security_alert(alert_id):
"""Resolve a security alert"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
note = request.form.get('note', '')
db = SessionLocal()
try:
alert = db.query(SecurityAlert).get(alert_id)
if not alert:
return jsonify({'success': False, 'error': 'Alert not found'}), 404
alert.status = 'resolved'
alert.resolution_note = note
if not alert.acknowledged_by:
alert.acknowledged_by = current_user.id
alert.acknowledged_at = datetime.now()
# Log audit
if SECURITY_SERVICE_AVAILABLE:
log_audit(db, 'alert.resolve', 'security_alert', alert_id,
details={'alert_type': alert.alert_type, 'note': note})
db.commit()
flash('Alert został rozwiązany.', 'success')
return redirect(url_for('admin_security'))
finally:
db.close()
@app.route('/admin/security/unlock-account/<int:user_id>', methods=['POST'])
@login_required
def unlock_account(user_id):
"""Unlock a locked user account"""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
db = SessionLocal()
try:
user = db.query(User).get(user_id)
if not user:
return jsonify({'success': False, 'error': 'User not found'}), 404
user.locked_until = None
user.failed_login_attempts = 0
# Log audit
if SECURITY_SERVICE_AVAILABLE:
log_audit(db, 'user.unlock', 'user', user_id, user.email)
db.commit()
flash(f'Konto {user.email} zostało odblokowane.', 'success')
return redirect(url_for('admin_security'))
finally:
db.close()
# ============================================================
# HONEYPOT ENDPOINTS (trap for malicious bots)
# ============================================================

View File

@ -209,6 +209,11 @@ class User(Base, UserMixin):
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
# Two-Factor Authentication (TOTP)
totp_secret = Column(String(32), nullable=True) # Base32 encoded secret
totp_enabled = Column(Boolean, default=False)
totp_backup_codes = Column(StringArray, nullable=True) # Emergency backup codes
# Relationships
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
@ -2577,6 +2582,103 @@ class EmailLog(Base):
return f"<EmailLog {self.id} {self.email_type} -> {self.recipient_email} ({self.status})>"
# ============================================================
# SECURITY & AUDIT
# ============================================================
class AuditLog(Base):
"""
Audit log dla śledzenia działań administracyjnych.
Śledzi wszystkie wrażliwe operacje wykonywane przez adminów:
- Moderacja newsów (approve/reject)
- Zmiany składek członkowskich
- Edycja profili firm
- Zmiany uprawnień użytkowników
- Operacje na wydarzeniach
Created: 2026-01-14
"""
__tablename__ = 'audit_logs'
id = Column(Integer, primary_key=True)
# Kto wykonał akcję
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
user_email = Column(String(255), nullable=False) # Zachowane nawet po usunięciu usera
# Co zostało wykonane
action = Column(String(100), nullable=False, index=True) # np. 'news.approve', 'company.edit', 'user.ban'
entity_type = Column(String(50), nullable=False, index=True) # np. 'news', 'company', 'user', 'event'
entity_id = Column(Integer, nullable=True) # ID encji której dotyczy akcja
entity_name = Column(String(255), nullable=True) # Nazwa encji (dla czytelności)
# Szczegóły
details = Column(JSONBType, nullable=True) # Dodatkowe dane: old_value, new_value, reason
# Kontekst requestu
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
request_path = Column(String(500), nullable=True)
# Timestamp
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# Relacje
user = relationship('User', backref='audit_logs')
def __repr__(self):
return f"<AuditLog {self.id} {self.user_email} {self.action} on {self.entity_type}:{self.entity_id}>"
class SecurityAlert(Base):
"""
Alerty bezpieczeństwa wysyłane emailem.
Śledzi:
- Zbyt wiele nieudanych logowań
- Próby dostępu do honeypotów
- Podejrzane wzorce aktywności
- Blokady kont
Created: 2026-01-14
"""
__tablename__ = 'security_alerts'
id = Column(Integer, primary_key=True)
# Typ alertu
alert_type = Column(String(50), nullable=False, index=True)
# Typy: 'brute_force', 'honeypot_hit', 'account_locked', 'suspicious_activity', 'geo_blocked'
severity = Column(String(20), nullable=False, default='medium') # low, medium, high, critical
# Kontekst
ip_address = Column(String(45), nullable=True, index=True)
user_email = Column(String(255), nullable=True)
details = Column(JSONBType, nullable=True) # Dodatkowe dane
# Status alertu
status = Column(String(20), default='new', index=True) # new, acknowledged, resolved
acknowledged_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
acknowledged_at = Column(DateTime, nullable=True)
resolution_note = Column(Text, nullable=True)
# Email notification
email_sent = Column(Boolean, default=False)
email_sent_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relacje
acknowledger = relationship('User', foreign_keys=[acknowledged_by])
def __repr__(self):
return f"<SecurityAlert {self.id} {self.alert_type} ({self.severity}) from {self.ip_address}>"
# ============================================================
# DATABASE INITIALIZATION
# ============================================================

455
security_service.py Normal file
View File

@ -0,0 +1,455 @@
"""
Norda Biznes - Security Service
================================
Security utilities for NordaBiz platform:
- Audit logging (admin action tracking)
- Security alerting (email notifications)
- GeoIP blocking (Poland only)
- 2FA (TOTP) helpers
Author: Norda Biznes Development Team
Created: 2026-01-14
"""
import os
import logging
import secrets
from datetime import datetime
from functools import wraps
from flask import request, abort, flash, redirect, url_for
from flask_login import current_user
logger = logging.getLogger(__name__)
# ============================================================
# AUDIT LOG
# ============================================================
def log_audit(db, action: str, entity_type: str, entity_id: int = None,
entity_name: str = None, details: dict = None, user=None):
"""
Log an administrative action to the audit log.
Args:
db: Database session
action: Action identifier (e.g., 'news.approve', 'company.edit')
entity_type: Type of entity ('news', 'company', 'user', 'event')
entity_id: ID of the affected entity
entity_name: Human-readable name of the entity
details: Additional details (old_value, new_value, reason, etc.)
user: User performing the action (defaults to current_user)
Example:
log_audit(db, 'news.approve', 'news', news_id=123,
entity_name='Artykuł o firmie XYZ',
details={'previous_status': 'pending'})
"""
from database import AuditLog
if user is None:
user = current_user if current_user.is_authenticated else None
audit_entry = AuditLog(
user_id=user.id if user else None,
user_email=user.email if user else 'system',
action=action,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
details=details,
ip_address=get_client_ip(),
user_agent=request.user_agent.string[:500] if request else None,
request_path=request.path if request else None
)
db.add(audit_entry)
# Don't commit here - let the caller manage the transaction
logger.info(f"AUDIT: {audit_entry.user_email} performed {action} on {entity_type}:{entity_id}")
def get_client_ip():
"""Get real client IP, handling X-Forwarded-For from reverse proxy."""
if request:
forwarded_for = request.headers.get('X-Forwarded-For', '')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
return request.remote_addr
return None
# ============================================================
# SECURITY ALERTING
# ============================================================
# Alert thresholds
ALERT_THRESHOLDS = {
'brute_force': 5, # Failed logins from same IP
'honeypot_hit': 3, # Honeypot accesses from same IP
'account_locked': 1, # Always alert on account lockout
}
# Admin email for alerts
SECURITY_ALERT_EMAIL = os.getenv('SECURITY_ALERT_EMAIL', 'admin@nordabiznes.pl')
def create_security_alert(db, alert_type: str, severity: str = 'medium',
ip_address: str = None, user_email: str = None,
details: dict = None, send_email: bool = True):
"""
Create a security alert and optionally send email notification.
Args:
db: Database session
alert_type: Type of alert ('brute_force', 'honeypot_hit', 'account_locked', 'geo_blocked')
severity: Alert severity ('low', 'medium', 'high', 'critical')
ip_address: Source IP address
user_email: Associated user email (if any)
details: Additional context
send_email: Whether to send email notification
Returns:
SecurityAlert object
"""
from database import SecurityAlert
alert = SecurityAlert(
alert_type=alert_type,
severity=severity,
ip_address=ip_address or get_client_ip(),
user_email=user_email,
details=details or {}
)
db.add(alert)
db.flush() # Get the ID
logger.warning(f"SECURITY_ALERT: {alert_type} ({severity}) from {alert.ip_address}")
# Send email notification for high/critical alerts
if send_email and severity in ('high', 'critical'):
_send_alert_email(alert)
return alert
def _send_alert_email(alert):
"""Send email notification for a security alert."""
try:
from email_service import send_email, is_configured
if not is_configured():
logger.warning("Email service not configured, cannot send security alert email")
return False
subject = f"[ALERT] {alert.severity.upper()}: {alert.alert_type} - NordaBiznes"
body = f"""
<h2>Security Alert - NordaBiznes</h2>
<p><strong>Type:</strong> {alert.alert_type}</p>
<p><strong>Severity:</strong> {alert.severity.upper()}</p>
<p><strong>Time:</strong> {alert.created_at.strftime('%Y-%m-%d %H:%M:%S')}</p>
<p><strong>IP Address:</strong> {alert.ip_address or 'Unknown'}</p>
<p><strong>User Email:</strong> {alert.user_email or 'N/A'}</p>
<p><strong>Details:</strong></p>
<pre>{alert.details}</pre>
<hr>
<p>Review alerts at: <a href="https://nordabiznes.pl/admin/security">Security Dashboard</a></p>
"""
success = send_email(
to_email=SECURITY_ALERT_EMAIL,
subject=subject,
html_content=body
)
if success:
alert.email_sent = True
alert.email_sent_at = datetime.utcnow()
logger.info(f"Security alert email sent for alert {alert.id}")
return success
except Exception as e:
logger.error(f"Failed to send security alert email: {e}")
return False
# ============================================================
# GEOIP BLOCKING
# ============================================================
# GeoIP configuration
GEOIP_ENABLED = os.getenv('GEOIP_ENABLED', 'false').lower() == 'true'
GEOIP_DB_PATH = os.getenv('GEOIP_DB_PATH', '/var/lib/GeoIP/GeoLite2-Country.mmdb')
ALLOWED_COUNTRIES = {'PL'} # Only Poland allowed
GEOIP_WHITELIST = set(os.getenv('GEOIP_WHITELIST', '').split(',')) - {''} # Whitelisted IPs
# GeoIP reader (lazy loaded)
_geoip_reader = None
def get_geoip_reader():
"""Get or initialize GeoIP reader."""
global _geoip_reader
if _geoip_reader is not None:
return _geoip_reader
if not GEOIP_ENABLED:
return None
try:
import geoip2.database
if os.path.exists(GEOIP_DB_PATH):
_geoip_reader = geoip2.database.Reader(GEOIP_DB_PATH)
logger.info(f"GeoIP database loaded from {GEOIP_DB_PATH}")
else:
logger.warning(f"GeoIP database not found at {GEOIP_DB_PATH}")
except ImportError:
logger.warning("geoip2 package not installed, GeoIP blocking disabled")
except Exception as e:
logger.error(f"Failed to load GeoIP database: {e}")
return _geoip_reader
def get_country_code(ip_address: str) -> str:
"""
Get country code for an IP address.
Returns:
ISO 3166-1 alpha-2 country code (e.g., 'PL', 'DE') or None
"""
reader = get_geoip_reader()
if not reader:
return None
try:
response = reader.country(ip_address)
return response.country.iso_code
except Exception:
return None
def is_ip_allowed(ip_address: str = None) -> bool:
"""
Check if an IP address is allowed (from Poland or whitelisted).
Args:
ip_address: IP to check (defaults to current request IP)
Returns:
True if allowed, False if blocked
"""
if not GEOIP_ENABLED:
return True
if ip_address is None:
ip_address = get_client_ip()
if not ip_address:
return True
# Check whitelist first
if ip_address in GEOIP_WHITELIST:
return True
# Local/private IPs are always allowed
if ip_address.startswith(('10.', '192.168.', '172.', '127.')):
return True
country = get_country_code(ip_address)
# If we can't determine country, allow (fail open)
if country is None:
return True
return country in ALLOWED_COUNTRIES
def geoip_check():
"""
Decorator to block non-Polish IPs.
Usage:
@app.route('/sensitive-endpoint')
@geoip_check()
def sensitive_endpoint():
...
"""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not is_ip_allowed():
ip = get_client_ip()
country = get_country_code(ip)
logger.warning(f"GEOIP_BLOCKED ip={ip} country={country} path={request.path}")
# Create alert for blocked access
try:
from database import SessionLocal
db = SessionLocal()
create_security_alert(
db, 'geo_blocked', 'low',
ip_address=ip,
details={'country': country, 'path': request.path}
)
db.commit()
db.close()
except Exception as e:
logger.error(f"Failed to create geo block alert: {e}")
abort(403)
return f(*args, **kwargs)
return wrapped
return decorator
# ============================================================
# TWO-FACTOR AUTHENTICATION (TOTP)
# ============================================================
TOTP_ISSUER = 'NordaBiznes'
def generate_totp_secret() -> str:
"""Generate a new TOTP secret key."""
try:
import pyotp
return pyotp.random_base32()
except ImportError:
logger.error("pyotp package not installed")
return None
def get_totp_uri(user) -> str:
"""
Get TOTP provisioning URI for QR code generation.
Args:
user: User object with totp_secret set
Returns:
otpauth:// URI for authenticator apps
"""
if not user.totp_secret:
return None
try:
import pyotp
totp = pyotp.TOTP(user.totp_secret)
return totp.provisioning_uri(
name=user.email,
issuer_name=TOTP_ISSUER
)
except ImportError:
logger.error("pyotp package not installed")
return None
def verify_totp(user, code: str) -> bool:
"""
Verify a TOTP code for a user.
Args:
user: User object with totp_secret
code: 6-digit TOTP code to verify
Returns:
True if valid, False otherwise
"""
if not user.totp_secret:
return False
try:
import pyotp
totp = pyotp.TOTP(user.totp_secret)
return totp.verify(code, valid_window=1) # Allow 1 step drift
except ImportError:
logger.error("pyotp package not installed")
return False
def generate_backup_codes(count: int = 8) -> list:
"""
Generate backup codes for 2FA recovery.
Returns:
List of 8-character backup codes
"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = secrets.token_hex(4).upper()
codes.append(code)
return codes
def verify_backup_code(user, code: str, db) -> bool:
"""
Verify and consume a backup code.
Args:
user: User object with totp_backup_codes
code: Backup code to verify
db: Database session to update user
Returns:
True if valid (code is consumed), False otherwise
"""
if not user.totp_backup_codes:
return False
code = code.upper().strip()
if code in user.totp_backup_codes:
# Remove the used code
remaining_codes = [c for c in user.totp_backup_codes if c != code]
user.totp_backup_codes = remaining_codes
db.commit()
logger.info(f"Backup code used for user {user.email}, {len(remaining_codes)} codes remaining")
return True
return False
def requires_2fa(f):
"""
Decorator for routes that require 2FA verification.
If user has 2FA enabled but hasn't verified this session,
redirect to 2FA verification page.
Usage:
@app.route('/admin/sensitive')
@login_required
@requires_2fa
def admin_sensitive():
...
"""
@wraps(f)
def wrapped(*args, **kwargs):
from flask import session
if not current_user.is_authenticated:
return redirect(url_for('login'))
# If user has 2FA enabled and hasn't verified this session
if current_user.totp_enabled and not session.get('2fa_verified'):
flash('Wymagana weryfikacja 2FA.', 'warning')
session['2fa_next'] = request.url
return redirect(url_for('verify_2fa'))
return f(*args, **kwargs)
return wrapped
# ============================================================
# INITIALIZATION
# ============================================================
def init_security_service():
"""Initialize security service (load GeoIP database, etc.)."""
if GEOIP_ENABLED:
get_geoip_reader()
logger.info("Security service initialized")

View File

@ -0,0 +1,434 @@
{% extends "base.html" %}
{% block title %}Panel bezpieczeństwa - Admin{% endblock %}
{% block extra_css %}
<style>
.security-header {
margin-bottom: var(--spacing-xl);
}
.security-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.security-header p {
color: var(--text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-align: center;
}
.stat-card.alert {
border-color: #fcd34d;
background: #fffbeb;
}
.stat-card.danger {
border-color: #fca5a5;
background: #fef2f2;
}
.stat-value {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.section-header h2 {
font-size: var(--font-size-lg);
color: var(--text-primary);
margin: 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--background);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.data-table td {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.data-table tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
font-weight: 500;
}
.badge-new {
background: #fee2e2;
color: #991b1b;
}
.badge-acknowledged {
background: #fef3c7;
color: #92400e;
}
.badge-resolved {
background: #dcfce7;
color: #166534;
}
.badge-low {
background: #e0e7ff;
color: #3730a3;
}
.badge-medium {
background: #fef3c7;
color: #92400e;
}
.badge-high {
background: #fee2e2;
color: #991b1b;
}
.badge-critical {
background: #7f1d1d;
color: white;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-xs);
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-secondary {
background: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-danger {
background: var(--error);
color: white;
}
.action-buttons {
display: flex;
gap: var(--spacing-xs);
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.ip-address {
font-family: monospace;
font-size: var(--font-size-xs);
background: var(--background);
padding: 2px 6px;
border-radius: var(--radius);
}
.timestamp {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.alert-type {
text-transform: uppercase;
font-size: var(--font-size-xs);
font-weight: 600;
}
.locked-row {
background: #fef2f2;
}
.tabs {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--border);
padding-bottom: var(--spacing-md);
}
.tab {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
border-radius: var(--radius);
}
.tab.active {
background: var(--primary);
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
{% endblock %}
{% block content %}
<div class="security-header">
<h1>🛡️ Panel bezpieczeństwa</h1>
<p>Monitoring alertów, audit log i zarządzanie bezpieczeństwem</p>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card {% if stats.new_alerts > 0 %}alert{% endif %}">
<div class="stat-value">{{ stats.new_alerts }}</div>
<div class="stat-label">Nowe alerty</div>
</div>
<div class="stat-card {% if stats.locked_accounts > 0 %}danger{% endif %}">
<div class="stat-value">{{ stats.locked_accounts }}</div>
<div class="stat-label">Zablokowane konta</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.users_with_2fa }}/{{ stats.total_admins }}</div>
<div class="stat-label">Admini z 2FA</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.alert_breakdown|sum(attribute='value') if stats.alert_breakdown else 0 }}</div>
<div class="stat-label">Wszystkie alerty</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="showTab('alerts')">Alerty bezpieczeństwa</button>
<button class="tab" onclick="showTab('audit')">Audit log</button>
<button class="tab" onclick="showTab('locked')">Zablokowane konta</button>
</div>
<!-- Alerts Tab -->
<div id="tab-alerts" class="tab-content active">
<div class="section">
<div class="section-header">
<h2>🚨 Alerty bezpieczeństwa</h2>
</div>
{% if alerts %}
<table class="data-table">
<thead>
<tr>
<th>Typ</th>
<th>Ważność</th>
<th>IP</th>
<th>Użytkownik</th>
<th>Status</th>
<th>Data</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td><span class="alert-type">{{ alert.alert_type }}</span></td>
<td><span class="badge badge-{{ alert.severity }}">{{ alert.severity }}</span></td>
<td><span class="ip-address">{{ alert.ip_address or '-' }}</span></td>
<td>{{ alert.user_email or '-' }}</td>
<td><span class="badge badge-{{ alert.status }}">{{ alert.status }}</span></td>
<td><span class="timestamp">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td>
{% if alert.status == 'new' %}
<div class="action-buttons">
<form method="POST" action="{{ url_for('acknowledge_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-secondary">Potwierdź</button>
</form>
<form method="POST" action="{{ url_for('resolve_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-primary">Rozwiąż</button>
</form>
</div>
{% elif alert.status == 'acknowledged' %}
<form method="POST" action="{{ url_for('resolve_security_alert', alert_id=alert.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-primary">Rozwiąż</button>
</form>
{% else %}
<span class="timestamp">Rozwiązany</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>✅ Brak alertów bezpieczeństwa</p>
</div>
{% endif %}
</div>
</div>
<!-- Audit Log Tab -->
<div id="tab-audit" class="tab-content">
<div class="section">
<div class="section-header">
<h2>📋 Audit log</h2>
</div>
{% if audit_logs %}
<table class="data-table">
<thead>
<tr>
<th>Użytkownik</th>
<th>Akcja</th>
<th>Encja</th>
<th>IP</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{% for log in audit_logs %}
<tr>
<td>{{ log.user_email }}</td>
<td><code>{{ log.action }}</code></td>
<td>
{{ log.entity_type }}
{% if log.entity_id %}:{{ log.entity_id }}{% endif %}
{% if log.entity_name %}<br><small>{{ log.entity_name }}</small>{% endif %}
</td>
<td><span class="ip-address">{{ log.ip_address or '-' }}</span></td>
<td><span class="timestamp">{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Brak wpisów w audit log</p>
</div>
{% endif %}
</div>
</div>
<!-- Locked Accounts Tab -->
<div id="tab-locked" class="tab-content">
<div class="section">
<div class="section-header">
<h2>🔒 Zablokowane konta</h2>
</div>
{% if locked_accounts %}
<table class="data-table">
<thead>
<tr>
<th>Email</th>
<th>Nieudane próby</th>
<th>Zablokowane do</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
{% for user in locked_accounts %}
<tr class="locked-row">
<td>{{ user.email }}</td>
<td>{{ user.failed_login_attempts }}</td>
<td><span class="timestamp">{{ user.locked_until.strftime('%Y-%m-%d %H:%M') }}</span></td>
<td>
<form method="POST" action="{{ url_for('unlock_account', user_id=user.id) }}" style="display:inline;">
{{ csrf_token() }}
<button type="submit" class="btn-sm btn-danger" onclick="return confirm('Odblokować konto {{ user.email }}?')">
Odblokuj
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>✅ Brak zablokowanych kont</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
function showTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
// Show selected tab
document.getElementById('tab-' + tabName).classList.add('active');
event.target.classList.add('active');
}
{% endblock %}

View File

@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Kody zapasowe 2FA - Norda Biznes Hub{% endblock %}
{% block container_class %}container-narrow{% endblock %}
{% block extra_css %}
<style>
.backup-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-2xl) 0;
}
.backup-card {
background-color: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.backup-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.backup-header .icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
}
.backup-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.success-message {
background-color: #dcfce7;
color: #166534;
padding: var(--spacing-md);
border-radius: var(--radius);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.warning-box {
background-color: #fef3c7;
border: 1px solid #fcd34d;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.warning-box h3 {
color: #92400e;
font-size: var(--font-size-base);
margin-bottom: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.warning-box p {
color: #78350f;
font-size: var(--font-size-sm);
margin: 0;
}
.codes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.code-item {
background-color: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
font-family: monospace;
font-size: var(--font-size-lg);
text-align: center;
letter-spacing: 0.1em;
border: 1px solid var(--border);
}
.actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: var(--spacing-md) var(--spacing-xl);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-secondary {
background-color: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn:hover {
opacity: 0.9;
}
.instructions {
background-color: var(--background);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-top: var(--spacing-xl);
}
.instructions h3 {
font-size: var(--font-size-base);
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.instructions ul {
margin: 0;
padding-left: var(--spacing-lg);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.instructions li {
margin-bottom: var(--spacing-xs);
}
@media print {
.backup-card {
box-shadow: none;
}
.actions, .instructions {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="backup-container">
<div class="backup-card">
<div class="backup-header">
<div class="icon"></div>
<h1>2FA zostało włączone!</h1>
</div>
<div class="success-message">
Uwierzytelnianie dwuskładnikowe jest teraz aktywne dla Twojego konta.
</div>
<div class="warning-box">
<h3>⚠️ Zapisz te kody w bezpiecznym miejscu!</h3>
<p>
Kody zapasowe pozwolą Ci zalogować się, jeśli stracisz dostęp do aplikacji uwierzytelniającej.
Każdy kod można użyć tylko raz. Po zamknięciu tej strony nie będziesz mógł ich ponownie zobaczyć.
</p>
</div>
<div class="codes-grid">
{% for code in backup_codes %}
<div class="code-item">{{ code }}</div>
{% endfor %}
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.print();">
🖨️ Drukuj
</button>
<button class="btn btn-secondary" onclick="copyAllCodes();">
📋 Kopiuj wszystkie
</button>
<a href="{{ url_for('settings_2fa') }}" class="btn btn-primary">
Kontynuuj →
</a>
</div>
<div class="instructions">
<h3>Jak używać kodów zapasowych:</h3>
<ul>
<li>Podczas logowania wybierz "Użyj kodu zapasowego"</li>
<li>Wprowadź jeden z powyższych kodów</li>
<li>Każdy kod działa tylko raz - po użyciu jest nieaktywny</li>
<li>Gdy zostanie Ci mało kodów, wygeneruj nowe w ustawieniach</li>
</ul>
</div>
</div>
</div>
<script>
function copyAllCodes() {
const codes = [{% for code in backup_codes %}'{{ code }}'{% if not loop.last %}, {% endif %}{% endfor %}];
const text = 'NordaBiznes - Kody zapasowe 2FA\n\n' + codes.join('\n');
navigator.clipboard.writeText(text).then(() => {
alert('Kody zostały skopiowane do schowka!');
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block title %}Ustawienia 2FA - Norda Biznes Hub{% endblock %}
{% block container_class %}container-narrow{% endblock %}
{% block extra_css %}
<style>
.settings-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-2xl) 0;
}
.settings-card {
background-color: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.settings-header {
margin-bottom: var(--spacing-xl);
}
.settings-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.settings-header p {
color: var(--text-secondary);
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
}
.status-badge.enabled {
background-color: #dcfce7;
color: #166534;
}
.status-badge.disabled {
background-color: #fee2e2;
color: #991b1b;
}
.info-box {
background-color: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-box h3 {
font-size: var(--font-size-base);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.info-box p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: 0;
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.form-input {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-base);
}
.btn {
padding: var(--spacing-md) var(--spacing-xl);
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-dark, #1d4ed8);
}
.btn-danger {
background-color: var(--error);
color: white;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.btn-secondary {
background-color: var(--background);
color: var(--text-primary);
border: 1px solid var(--border);
}
.actions {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.back-link {
margin-top: var(--spacing-xl);
text-align: center;
}
.back-link a {
color: var(--text-secondary);
text-decoration: none;
}
</style>
{% endblock %}
{% block content %}
<div class="settings-container">
<div class="settings-card">
<div class="settings-header">
<h1>🔐 Uwierzytelnianie dwuskładnikowe (2FA)</h1>
<p>Dodatkowa warstwa ochrony dla Twojego konta</p>
</div>
<div class="info-box">
<h3>Status 2FA</h3>
<p>
{% if totp_enabled %}
<span class="status-badge enabled">✓ Włączone</span>
<br><br>
Masz {{ backup_codes_count }} kodów zapasowych pozostałych.
{% else %}
<span class="status-badge disabled">✗ Wyłączone</span>
<br><br>
Zalecamy włączenie 2FA dla lepszego bezpieczeństwa konta.
{% endif %}
</p>
</div>
{% if not totp_enabled %}
<!-- Enable 2FA -->
<form method="POST" action="{{ url_for('settings_2fa') }}">
{{ csrf_token() }}
<input type="hidden" name="action" value="setup">
<button type="submit" class="btn btn-primary">
Włącz 2FA
</button>
</form>
{% else %}
<!-- 2FA is enabled - show disable and regenerate options -->
<div class="actions">
<form method="POST" action="{{ url_for('settings_2fa') }}" onsubmit="return confirm('Czy na pewno chcesz wyłączyć 2FA? To zmniejszy bezpieczeństwo Twojego konta.');">
{{ csrf_token() }}
<input type="hidden" name="action" value="disable">
<div class="form-group">
<label class="form-label">Kod weryfikacyjny (aby wyłączyć)</label>
<input type="text" name="code" class="form-input" maxlength="6" pattern="[0-9]{6}" placeholder="000000" required>
</div>
<button type="submit" class="btn btn-danger">
Wyłącz 2FA
</button>
</form>
</div>
<hr style="margin: var(--spacing-xl) 0; border: none; border-top: 1px solid var(--border);">
<h3 style="margin-bottom: var(--spacing-md);">Kody zapasowe</h3>
<p style="color: var(--text-secondary); font-size: var(--font-size-sm); margin-bottom: var(--spacing-lg);">
Kody zapasowe pozwalają zalogować się gdy nie masz dostępu do aplikacji uwierzytelniającej.
Każdy kod można użyć tylko raz.
</p>
<form method="POST" action="{{ url_for('settings_2fa') }}">
{{ csrf_token() }}
<input type="hidden" name="action" value="regenerate_backup">
<div class="form-group">
<label class="form-label">Kod weryfikacyjny (aby wygenerować nowe kody)</label>
<input type="text" name="code" class="form-input" maxlength="6" pattern="[0-9]{6}" placeholder="000000" required>
</div>
<button type="submit" class="btn btn-secondary">
Wygeneruj nowe kody zapasowe
</button>
</form>
{% endif %}
<div class="back-link">
<a href="{{ url_for('dashboard') }}">← Powrót do panelu</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}Konfiguracja 2FA - Norda Biznes Hub{% endblock %}
{% block container_class %}container-narrow{% endblock %}
{% block extra_css %}
<style>
.setup-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-2xl) 0;
}
.setup-card {
background-color: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.setup-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.setup-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.setup-steps {
margin-bottom: var(--spacing-xl);
}
.step {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.step-number {
width: 32px;
height: 32px;
background-color: var(--primary);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.step-content h3 {
font-size: var(--font-size-base);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.step-content p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0;
}
.qr-container {
text-align: center;
padding: var(--spacing-xl);
background-color: white;
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-lg);
}
.qr-code {
max-width: 200px;
margin: 0 auto var(--spacing-md);
}
.qr-code img {
width: 100%;
height: auto;
}
.secret-key {
background-color: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius);
font-family: monospace;
font-size: var(--font-size-sm);
word-break: break-all;
margin-top: var(--spacing-md);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.form-input {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-xl);
text-align: center;
letter-spacing: 0.5em;
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.btn-primary {
width: 100%;
padding: var(--spacing-md) var(--spacing-xl);
background-color: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
}
.btn-primary:hover {
background-color: var(--primary-dark, #1d4ed8);
}
.cancel-link {
text-align: center;
margin-top: var(--spacing-lg);
}
.cancel-link a {
color: var(--text-secondary);
text-decoration: none;
}
.apps-list {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
margin-top: var(--spacing-sm);
}
.app-badge {
background-color: var(--background);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
</style>
{% endblock %}
{% block content %}
<div class="setup-container">
<div class="setup-card">
<div class="setup-header">
<h1>🔐 Konfiguracja 2FA</h1>
</div>
<div class="setup-steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h3>Pobierz aplikację uwierzytelniającą</h3>
<p>Użyj jednej z poniższych aplikacji na swoim telefonie:</p>
<div class="apps-list">
<span class="app-badge">Google Authenticator</span>
<span class="app-badge">Microsoft Authenticator</span>
<span class="app-badge">Authy</span>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h3>Zeskanuj kod QR</h3>
<p>Otwórz aplikację i zeskanuj poniższy kod QR:</p>
</div>
</div>
</div>
<div class="qr-container">
<!-- QR Code generated from URI -->
<div class="qr-code">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={{ qr_uri|urlencode }}" alt="QR Code">
</div>
<p style="font-size: var(--font-size-sm); color: var(--text-secondary);">
Nie możesz zeskanować? Wprowadź ręcznie:
</p>
<div class="secret-key">{{ secret }}</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h3>Wprowadź kod weryfikacyjny</h3>
<p>Wpisz 6-cyfrowy kod z aplikacji:</p>
</div>
</div>
<form method="POST" action="{{ url_for('settings_2fa') }}">
{{ csrf_token() }}
<input type="hidden" name="action" value="verify_setup">
<div class="form-group">
<input type="text" name="code" class="form-input"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
placeholder="000000" autocomplete="one-time-code" required>
</div>
<button type="submit" class="btn-primary">
Aktywuj 2FA
</button>
</form>
<div class="cancel-link">
<a href="{{ url_for('settings_2fa') }}">Anuluj</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block title %}Weryfikacja 2FA - Norda Biznes Hub{% endblock %}
{% block container_class %}container-narrow{% endblock %}
{% block extra_css %}
<style>
.auth-container {
max-width: 480px;
margin: 0 auto;
padding: var(--spacing-2xl) 0;
}
.auth-card {
background-color: var(--surface);
padding: var(--spacing-2xl);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.auth-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-header .icon {
font-size: 3rem;
margin-bottom: var(--spacing-md);
}
.auth-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.auth-header p {
color: var(--text-secondary);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: var(--spacing-sm);
color: var(--text-primary);
}
.form-input {
width: 100%;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-xl);
font-family: var(--font-family);
text-align: center;
letter-spacing: 0.5em;
transition: var(--transition);
}
.form-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.btn-primary {
width: 100%;
padding: var(--spacing-md) var(--spacing-xl);
background-color: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
transition: var(--transition);
}
.btn-primary:hover {
background-color: var(--primary-dark, #1d4ed8);
}
.backup-link {
text-align: center;
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.backup-link a {
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
}
.backup-link a:hover {
color: var(--primary);
}
.cancel-link {
text-align: center;
margin-top: var(--spacing-md);
}
.cancel-link a {
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<div class="icon">🔐</div>
<h1>Weryfikacja dwuetapowa</h1>
<p>Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej</p>
</div>
<form method="POST" action="{{ url_for('verify_2fa') }}">
{{ csrf_token() }}
<div class="form-group">
<label class="form-label" for="code">Kod weryfikacyjny</label>
<input type="text" id="code" name="code" class="form-input"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
placeholder="000000" autocomplete="one-time-code" autofocus required>
</div>
<button type="submit" class="btn-primary">
Zweryfikuj
</button>
</form>
<div class="backup-link">
<a href="#" onclick="document.getElementById('backup-form').style.display='block'; this.parentElement.style.display='none'; return false;">
Użyj kodu zapasowego
</a>
</div>
<form id="backup-form" method="POST" action="{{ url_for('verify_2fa') }}" style="display:none; margin-top: var(--spacing-lg);">
{{ csrf_token() }}
<input type="hidden" name="use_backup" value="1">
<div class="form-group">
<label class="form-label" for="backup-code">Kod zapasowy</label>
<input type="text" id="backup-code" name="code" class="form-input"
maxlength="8" placeholder="XXXXXXXX" style="letter-spacing: 0.2em;">
</div>
<button type="submit" class="btn-primary">
Użyj kodu zapasowego
</button>
</form>
<div class="cancel-link">
<a href="{{ url_for('login') }}">Anuluj i wróć do logowania</a>
</div>
</div>
</div>
{% endblock %}