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:
parent
7226e098f6
commit
0dba52e9c4
355
app.py
355
app.py
@ -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)
|
||||
# ============================================================
|
||||
|
||||
102
database.py
102
database.py
@ -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
455
security_service.py
Normal 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")
|
||||
434
templates/admin/security_dashboard.html
Normal file
434
templates/admin/security_dashboard.html
Normal 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 %}
|
||||
217
templates/auth/2fa_backup_codes.html
Normal file
217
templates/auth/2fa_backup_codes.html
Normal 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 %}
|
||||
221
templates/auth/2fa_settings.html
Normal file
221
templates/auth/2fa_settings.html
Normal 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 %}
|
||||
236
templates/auth/2fa_setup.html
Normal file
236
templates/auth/2fa_setup.html
Normal 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 %}
|
||||
168
templates/auth/verify_2fa.html
Normal file
168
templates/auth/verify_2fa.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user