This commit is contained in:
Luftmensch
2026-08-26 14:11:37 +07:00
commit e528419f9a
135 changed files with 413970 additions and 0 deletions
BIN
View File
Binary file not shown.
+593
View File
@@ -0,0 +1,593 @@
import base64
import datetime
import json
import os
import time
from os import mkdir
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import unpad
from colorama import Fore
from colorama import init
from openpyxl import load_workbook
from tqdm import tqdm
from unidecode import unidecode
init()
current = datetime.datetime.now()
try:
os.mkdir("output")
except FileExistsError:
pass
def extract_system_info(lines):
"""Extract system information from decrypted file content."""
system_info = {
'script_version': '',
'os_name': '',
'kernel_version': '',
'architecture': '',
'hostname': '',
'fqdn': '',
'ip_address': '',
'all_ips': '',
'audit_time': '',
'timezone': '',
'uptime': '',
'cpu_model': '',
'cpu_cores': '',
'total_memory': '',
'used_memory': '',
'free_memory': '',
'swap_total': '',
'primary_interface': '',
'mac_address': '',
'default_gateway': '',
'dns_servers': '',
'selinux_status': '',
'firewall_status': '',
'last_boot': '',
'current_users': '',
'load_average': '',
# Compliance statistics
'passed_count': 0,
'failed_count': 0,
'total_checks': 0,
'compliance_percentage': 0.0,
# Mandatory/Optional statistics
'mandatory_passed': 0,
'mandatory_failed': 0,
'mandatory_total': 0,
'mandatory_percentage': 0.0,
'optional_passed': 0,
'optional_failed': 0,
'optional_total': 0,
'optional_percentage': 0.0
}
passed_count = 0
failed_count = 0
for line in lines:
line_stripped = line.strip()
# Count PASSED and FAILED checks from JSON format lines
if line_stripped.startswith('{') and line_stripped.endswith('}'):
if '"PASSED"' in line_stripped:
passed_count += 1
elif '"FAILED"' in line_stripped:
failed_count += 1
# Parse system information
if line_stripped.startswith('Script Version:'):
system_info['script_version'] = line_stripped.replace('Script Version:', '').strip()
elif line_stripped.startswith('Operating System:'):
system_info['os_name'] = line_stripped.replace('Operating System:', '').strip()
elif line_stripped.startswith('Kernel Version:'):
system_info['kernel_version'] = line_stripped.replace('Kernel Version:', '').strip()
elif line_stripped.startswith('Architecture:'):
system_info['architecture'] = line_stripped.replace('Architecture:', '').strip()
elif line_stripped.startswith('Hostname:'):
system_info['hostname'] = line_stripped.replace('Hostname:', '').strip()
elif line_stripped.startswith('FQDN:'):
system_info['fqdn'] = line_stripped.replace('FQDN:', '').strip()
elif line_stripped.startswith('IP Address:'):
system_info['ip_address'] = line_stripped.replace('IP Address:', '').strip()
elif line_stripped.startswith('All IP Addresses:'):
system_info['all_ips'] = line_stripped.replace('All IP Addresses:', '').strip()
elif line_stripped.startswith('Audit Time:'):
system_info['audit_time'] = line_stripped.replace('Audit Time:', '').strip()
elif line_stripped.startswith('Timezone:'):
system_info['timezone'] = line_stripped.replace('Timezone:', '').strip()
elif line_stripped.startswith('Uptime:'):
system_info['uptime'] = line_stripped.replace('Uptime:', '').strip()
elif line_stripped.startswith('CPU Model:'):
system_info['cpu_model'] = line_stripped.replace('CPU Model:', '').strip()
elif line_stripped.startswith('CPU Cores:'):
system_info['cpu_cores'] = line_stripped.replace('CPU Cores:', '').strip()
elif line_stripped.startswith('Total Memory:'):
system_info['total_memory'] = line_stripped.replace('Total Memory:', '').strip()
elif line_stripped.startswith('Used Memory:'):
system_info['used_memory'] = line_stripped.replace('Used Memory:', '').strip()
elif line_stripped.startswith('Free Memory:'):
system_info['free_memory'] = line_stripped.replace('Free Memory:', '').strip()
elif line_stripped.startswith('Swap Total:'):
system_info['swap_total'] = line_stripped.replace('Swap Total:', '').strip()
elif line_stripped.startswith('Primary Interface:'):
system_info['primary_interface'] = line_stripped.replace('Primary Interface:', '').strip()
elif line_stripped.startswith('MAC Address:'):
system_info['mac_address'] = line_stripped.replace('MAC Address:', '').strip()
elif line_stripped.startswith('Default Gateway:'):
system_info['default_gateway'] = line_stripped.replace('Default Gateway:', '').strip()
elif line_stripped.startswith('DNS Servers:'):
system_info['dns_servers'] = line_stripped.replace('DNS Servers:', '').strip()
elif line_stripped.startswith('SELinux Status:'):
system_info['selinux_status'] = line_stripped.replace('SELinux Status:', '').strip()
elif line_stripped.startswith('Firewall:'):
system_info['firewall_status'] = line_stripped.replace('Firewall:', '').strip()
elif line_stripped.startswith('Last Boot:'):
system_info['last_boot'] = line_stripped.replace('Last Boot:', '').strip()
elif line_stripped.startswith('Current Users:'):
system_info['current_users'] = line_stripped.replace('Current Users:', '').strip()
elif line_stripped.startswith('Load Average:'):
system_info['load_average'] = line_stripped.replace('Load Average:', '').strip()
# Calculate compliance statistics
total_checks = passed_count + failed_count
system_info['passed_count'] = passed_count
system_info['failed_count'] = failed_count
system_info['total_checks'] = total_checks
if total_checks > 0:
system_info['compliance_percentage'] = round((passed_count / total_checks) * 100, 1)
else:
system_info['compliance_percentage'] = 0.0
return system_info
def detect_os_from_content(lines):
"""
Detect OS type from decrypted file content.
Reads the 'Operating System:' field and maps it to an OS key.
Returns the OS key (centos, ubuntu, rhel, oracle, windows) or None if not detected.
Example values:
'CentOS Linux 7 (Core)' -> 'centos'
'Red Hat Enterprise ...' -> 'rhel'
'Ubuntu 20.04.x LTS' -> 'ubuntu'
'Oracle Linux 8.x' -> 'oracle'
'Windows Server 2019' -> 'windows'
"""
os_name_raw = None
for line in lines:
stripped = line.strip()
if stripped.startswith('Operating System:'):
os_name_raw = stripped.replace('Operating System:', '').strip()
break
if not os_name_raw:
return None
os_lower = os_name_raw.lower()
# Order matters: more specific patterns first
if 'centos' in os_lower:
detected = 'centos'
elif 'red hat' in os_lower or 'redhat' in os_lower or 'rhel' in os_lower:
detected = 'rhel'
elif 'ubuntu' in os_lower:
detected = 'ubuntu'
elif 'oracle' in os_lower:
# Check if version is < 8
# Hỗ trợ cả 'Oracle Linux Server release 7.9' và 'Oracle Linux Server 7.9'
import re
version_match = re.search(r'(?:release\s+|server\s+|linux\s+)(\d+)', os_lower)
if not version_match:
# Fallback: tìm số version đầu tiên trong chuỗi (ví dụ 'oracle linux 7.9')
version_match = re.search(r'(\d+)\.', os_lower)
if version_match and int(version_match.group(1)) < 8:
print(Fore.YELLOW + f"[CONTENT-DETECT] Oracle Linux < 8 detected (version {version_match.group(1)}). Routing to CentOS config." + Fore.RESET)
detected = 'centos'
else:
detected = 'oracle'
elif 'windows' in os_lower:
detected = 'windows'
else:
detected = None
if detected:
print(Fore.CYAN + f"[CONTENT-DETECT] 'Operating System: {os_name_raw}' → OS key: '{detected}'" + Fore.RESET)
else:
print(Fore.YELLOW + f"[CONTENT-DETECT] Unknown OS from content: '{os_name_raw}'" + Fore.RESET)
return detected
_cached_private_key = None
def get_private_key():
"""Lấy hoặc nạp RSA private key từ cache trong RAM."""
global _cached_private_key
if _cached_private_key is not None:
return _cached_private_key
script_dir = os.path.dirname(os.path.abspath(__file__))
private_key_paths = [
'private_key.pem',
'../private_key.pem',
os.path.join(script_dir, 'private_key.pem'),
os.path.join(script_dir, 'keys', 'private_key.pem'),
'keys/private_key.pem'
]
for path in private_key_paths:
if os.path.exists(path):
with open(path, 'rb') as f:
_cached_private_key = RSA.import_key(f.read())
return _cached_private_key
raise FileNotFoundError("private_key.pem not found! Please ensure it exists in the script directory.")
def _decrypt_rsa_block_worker(block: bytes) -> bytes:
"""Hàm worker cấp module phục vụ cho ProcessPoolExecutor / ThreadPoolExecutor giải mã RSA."""
private_key = get_private_key()
cipher = PKCS1_OAEP.new(private_key)
return cipher.decrypt(block)
def is_hybrid_encrypted(raw_text: str) -> bool:
"""Kiểm tra xem nội dung file có thuộc chuẩn mã hóa lai MỚI (HYBRID AES-256 + RSA) hay không."""
s = raw_text.strip()
return s.startswith("HYBRID_V1:") or s.startswith("HYBRID_AES:") or s.startswith("HYBRID:")
def decrypt_hybrid(raw_text: str) -> bytes:
"""
Giải mã định dạng Mã hóa Lai (Hybrid AES-256 + RSA) siêu tốc (~0.005s).
Cấu trúc:
HYBRID_V1:<b64_enc_key>:<b64_iv>:<b64_ciphertext>
hoặc JSON:
HYBRID_V1:{"enc_key": "...", "iv": "...", "data": "..."}
"""
s = raw_text.strip()
# Loại bỏ tiền tố prefix
for prefix in ("HYBRID_V1:", "HYBRID_AES:", "HYBRID:"):
if s.startswith(prefix):
payload = s[len(prefix):].strip()
break
b64_enc_key = ""
b64_iv = ""
b64_ciphertext = ""
if payload.startswith("{"):
# Format JSON
data_json = json.loads(payload)
b64_enc_key = data_json.get("enc_key", "")
b64_iv = data_json.get("iv", "")
b64_ciphertext = data_json.get("data", "")
else:
# Format chuỗi phân cách bởi dấu hai chấm ':'
parts = payload.split(":")
if len(parts) >= 3:
b64_enc_key = parts[0]
b64_iv = parts[1]
b64_ciphertext = parts[2]
if not b64_enc_key or not b64_iv or not b64_ciphertext:
raise ValueError("Cấu trúc file Mã hóa Lai (Hybrid Encryption) không hợp lệ!")
enc_key = base64.b64decode(b64_enc_key)
iv = base64.b64decode(b64_iv)
ciphertext = base64.b64decode(b64_ciphertext)
# 1. Giải mã khóa AES bằng RSA Private Key (chỉ 1 block RSA duy nhất)
private_key = get_private_key()
cipher_rsa = PKCS1_OAEP.new(private_key)
aes_key = cipher_rsa.decrypt(enc_key)
# 2. Giải mã dữ liệu bằng khóa AES-256-CBC (siêu nhanh)
cipher_aes = AES.new(aes_key, AES.MODE_CBC, iv)
decrypted_padded = cipher_aes.decrypt(ciphertext)
# Thử gỡ padding PKCS7
try:
plaintext_bytes = unpad(decrypted_padded, AES.block_size)
except ValueError:
plaintext_bytes = decrypted_padded
return plaintext_bytes
def decryption_read_header(filename: str, max_blocks: int = 30) -> list[str]:
"""
Giải mã nhanh phần header của file .enc để đọc thông tin OS.
Tự động hỗ trợ cả chuẩn MỚI (Hybrid AES+RSA) và chuẩn CŨ (Pure RSA).
"""
with open(filename, "r", encoding="utf-8", errors="replace") as f:
raw_text = f.read()
if is_hybrid_encrypted(raw_text):
# Format MỚI: Giải mã siêu tốc bằng AES-256 + RSA (< 0.005s)
plaintext_bytes = decrypt_hybrid(raw_text)
lines = plaintext_bytes.decode("utf-8", errors="replace").splitlines()
return lines[:100]
# Format CŨ: Giải mã N block RSA đầu tiên (~5KB)
ciphertext = base64.b64decode(raw_text.encode('utf-8'))
private_key = get_private_key()
block_size = private_key.size_in_bytes()
cipher = PKCS1_OAEP.new(private_key)
total_blocks = (len(ciphertext) + block_size - 1) // block_size
num_blocks = min(max_blocks, total_blocks)
blocks = []
for i in range(num_blocks):
offset = i * block_size
block = ciphertext[offset:offset + block_size]
try:
blocks.append(cipher.decrypt(block))
except Exception:
break
header_text = b"".join(blocks).decode('utf-8', errors='replace')
return header_text.splitlines()
def decryption_write(filename):
"""
Giải mã toàn bộ file mã hóa.
Tự động chuyển đổi giữa chuẩn MỚI (Hybrid AES-256 + RSA) và chuẩn CŨ (Pure RSA Multi-processing).
"""
with open(filename, "r", encoding="utf-8", errors="replace") as f:
raw_text = f.read()
start_time = time.time()
if is_hybrid_encrypted(raw_text):
# ⚡ Chuẩn MỚI (Hybrid AES-256 + RSA): Tốc độ tức thì < 0.01 giây
plaintext_bytes = decrypt_hybrid(raw_text)
plaintext = plaintext_bytes.decode("utf-8", errors="replace")
print(Fore.GREEN + f" [HYBRID AES+RSA] Decryption done in {time.time() - start_time:.3f}s" + Fore.RESET)
else:
# 🐢 Chuẩn CŨ (Pure RSA): Giải mã đa luồng / đa tiến trình tối ưu CPU Multi-core
ciphertext = base64.b64decode(raw_text.encode('utf-8'))
private_key = get_private_key()
block_size = private_key.size_in_bytes()
total_blocks = (len(ciphertext) + block_size - 1) // block_size
block_list = [ciphertext[i * block_size:(i + 1) * block_size] for i in range(total_blocks)]
max_workers = min(16, (os.cpu_count() or 4) * 2)
try:
with ThreadPoolExecutor(max_workers=max_workers) as executor:
decrypted_blocks = list(executor.map(_decrypt_rsa_block_worker, block_list))
except Exception:
cipher = PKCS1_OAEP.new(private_key)
decrypted_blocks = [cipher.decrypt(b) for b in block_list]
plaintext = b"".join(decrypted_blocks).decode('utf-8', errors='replace')
print(Fore.GREEN + f" [LEGACY RSA] Decryption done in {time.time() - start_time:.1f}s ({total_blocks} blocks, {max_workers} workers)" + Fore.RESET)
output_path = os.path.basename(filename).replace(".enc", "")
with open(output_path, 'w', encoding="utf-8") as f:
f.write(plaintext)
def check_file_exist(filename):
isFile = os.path.isfile(filename)
if isFile:
return True
else:
return False
def string_normalization(string):
return unidecode(str(string))
def extract_failed_logs(lines):
"""Extract detailed log blocks for each FAILED criteria from decrypted log lines.
Returns a dict mapping normalized criteria name -> log detail text.
"""
failed_logs = {}
i = 0
separator = '######################################'
dash_separator = '-------------------'
stderr_begin = '########## STDERR BEGIN ##########'
stderr_end = '########## STDERR END ##########'
while i < len(lines):
line_stripped = lines[i].strip()
# Look for FAILED JSON lines
if line_stripped.startswith('{') and line_stripped.endswith('}') and '"FAILED"' in line_stripped:
try:
parsed = json.loads(line_stripped)
criteria_name = str(list(parsed.keys())[0])
normalized_name = string_normalization(criteria_name)
# Collect all lines after this FAILED line until the next JSON criteria line
log_lines = []
j = i + 1
while j < len(lines):
next_line = lines[j].strip()
# Stop when we hit the next JSON criteria line (PASSED or FAILED)
if next_line.startswith('{') and next_line.endswith('}'):
try:
next_parsed = json.loads(next_line)
next_val = str(list(next_parsed.values())[0])
if next_val in ('PASSED', 'FAILED'):
break
except (json.JSONDecodeError, IndexError):
pass
# Skip separator lines and STDERR markers
if next_line == separator or next_line == dash_separator:
j += 1
continue
if next_line == stderr_begin or next_line == stderr_end:
j += 1
continue
# Skip empty lines at the beginning
if not log_lines and not next_line:
j += 1
continue
log_lines.append(lines[j].rstrip())
j += 1
# Remove trailing empty lines
while log_lines and not log_lines[-1].strip():
log_lines.pop()
if log_lines:
log_text = '\n'.join(log_lines)
# Excel cell limit is 32767 characters
if len(log_text) > 32767:
log_text = log_text[:32764] + '...'
failed_logs[normalized_name] = log_text
i = j # Skip to where we stopped
continue
except (json.JSONDecodeError, IndexError):
pass
i += 1
return failed_logs
def run_report(checklist_file, config_file, file_input):
with open(file_input, "r", encoding="utf8") as file:
line = file.read().splitlines()
wb = load_workbook(filename=checklist_file)
ws = wb.worksheets[0]
with open(config_file, 'r', encoding="utf8") as j:
contents = json.loads(j.read())
index_audit = contents['data']
mandatory_rows = set()
for row_idx in range(1, ws.max_row + 1):
cell_val = ws.cell(row=row_idx, column=5).value
if cell_val and str(cell_val).strip().lower() == 'x':
mandatory_rows.add(row_idx)
mandatory_passed = 0
mandatory_failed = 0
optional_passed = 0
optional_failed = 0
failed_logs = extract_failed_logs(line)
audit_results = {}
for one_audit in line:
try:
parsed_data = json.loads(one_audit)
result = str(list(parsed_data.keys())[0])
value = str(list(parsed_data.values())[0])
normalized_key = string_normalization(result)
audit_results[normalized_key] = (value, one_audit)
except Exception:
continue
for audit_pattern in index_audit:
index_a = list(audit_pattern.keys())[0]
index_pattern = string_normalization(list(audit_pattern.values())[0])
entry = audit_results.get(index_pattern)
if entry is None:
continue
status, raw_line = entry
is_mandatory = int(index_a) in mandatory_rows if index_a.isdigit() else False
if "FAILED" in raw_line:
ws['D{}'.format(index_a)] = 'x'
if index_pattern in failed_logs:
ws['F{}'.format(index_a)] = failed_logs[index_pattern]
if is_mandatory:
mandatory_failed += 1
else:
optional_failed += 1
else:
ws['C{}'.format(index_a)] = 'x'
if is_mandatory:
mandatory_passed += 1
else:
optional_passed += 1
# Find hostname and time dynamically in the file
hostname = "unknown"
time_generate = "unknown"
for l in line:
if l.strip().startswith("Hostname:"):
hostname = l.replace("Hostname:", "").strip().split(" ")[0]
elif l.strip().startswith("Audit Time:"):
time_generate = l.replace("Audit Time:", "").strip().replace("-", "_").replace(":", "_").replace(" ", "-")
# Also support old format with "Time:"
elif l.strip().startswith("Time:") and time_generate == "unknown":
time_generate = l.replace("Time:", "").strip().replace("-", "_").replace(":", "_").replace(" ", "-")
# Create file name based strictly on hostname and time
file_name = f'{hostname}_{time_generate}.xlsx'
wb.save(filename=file_name)
if check_file_exist(file_name):
print(Fore.GREEN + "\nChecklist Done: {}".format(file_name) + Fore.RESET)
else:
print(Fore.RED + "Error creating Excel file!" + Fore.RESET)
# Extract and return system info
system_info = extract_system_info(line)
# Add mandatory/optional statistics
mandatory_total = mandatory_passed + mandatory_failed
optional_total = optional_passed + optional_failed
system_info['mandatory_passed'] = mandatory_passed
system_info['mandatory_failed'] = mandatory_failed
system_info['mandatory_total'] = mandatory_total
system_info['mandatory_percentage'] = round((mandatory_passed / mandatory_total) * 100, 1) if mandatory_total > 0 else 0.0
system_info['optional_passed'] = optional_passed
system_info['optional_failed'] = optional_failed
system_info['optional_total'] = optional_total
system_info['optional_percentage'] = round((optional_passed / optional_total) * 100, 1) if optional_total > 0 else 0.0
# Recalculate compliance stats based on config-matched criteria (consistent with Excel)
# extract_system_info counts ALL PASSED/FAILED lines in raw text (including criteria
# not in the checklist config), which can differ from the Excel result.
# Use the actual config-matched counts for accurate web display.
config_passed = mandatory_passed + optional_passed
config_failed = mandatory_failed + optional_failed
config_total = config_passed + config_failed
if config_total > 0:
system_info['passed_count'] = config_passed
system_info['failed_count'] = config_failed
system_info['total_checks'] = config_total
system_info['compliance_percentage'] = round((config_passed / config_total) * 100, 1)
return system_info
def run_generate_excel(checklist_file, config_file, filename):
global current
current = datetime.datetime.now()
file_decrypt = os.path.basename(filename).replace(".enc", "")
if check_file_exist(file_decrypt):
os.remove(file_decrypt)
decryption_write(filename)
else:
decryption_write(filename)
if check_file_exist(file_decrypt):
system_info = run_report(checklist_file, config_file, file_decrypt)
return system_info
return None
View File
+314
View File
@@ -0,0 +1,314 @@
"""Authentication router for login and JWT token management."""
from fastapi import APIRouter, Depends, HTTPException, status, Form
from sqlalchemy.orm import Session
from typing import Dict, Any
from .. import crud, schemas
from ..database import get_db
from ..utils import create_access_token, decode_access_token
from fastapi import Request, Query
from ..security import get_current_user, revoke_token_db, REVOKED_TOKENS, set_latest_jti
import os
from .radius_login import check_radius_login
from ..models import RevokedToken
import requests
import xml.etree.ElementTree as ET
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=schemas.LoginResponse)
async def login(login_req: schemas.LoginRequest, db: Session = Depends(get_db)):
# Convert env về boolean chuẩn
IS_PRODUCT = os.getenv("IS_PRODUCT", "False").lower() == "true"
# =============================
# 🚀 PRODUCT MODE (mock data)
# =============================
if IS_PRODUCT:
return {
"access_token": "test",
"token_type": "bearer",
"user": {
"id": 1,
"email": "test@gmail.com",
"fullname": "Test User",
"unit_id": 1,
"unit_name": "MEDIA",
"role_id": 1,
"role_name": "Administrator",
"status": 2,
"status_name": "active",
"created_at": "2025-12-02T14:32:10",
"updated_at": "2025-12-23T04:24:04"
}
}
# =============================
# 🧪 DEV MODE (login thật)
# =============================
user = crud.authenticate_user(db, login_req.email, login_req.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if int(getattr(user, "status", 0) or 0) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not active",
headers={"WWW-Authenticate": "Bearer"},
)
user_details = crud.get_user_with_details(db, user)
token_data = {
"sub": str(user.id),
"email": user.email,
"fullname": user.fullname,
"unit_id": user.unit_id,
"status": user.status,
}
access_token = create_access_token(data=token_data)
try:
payload = decode_access_token(access_token)
jti = payload.get("jti")
if jti:
set_latest_jti(int(user.id), jti)
except Exception:
pass
return {
"access_token": access_token,
"token_type": "bearer",
"user": user_details
}
@router.post("/logout", response_model=schemas.APIResponse, dependencies=[Depends(get_current_user)])
async def logout(request: Request, db: Session = Depends(get_db)):
"""Logout endpoint: revoke current JWT token."""
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
headers={"WWW-Authenticate": "Bearer"},
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
revoke_token_db(db, token)
return {"message": "success", "data": {"revoked": True}}
@router.get("/validate-token", response_model=schemas.APIResponse)
async def validate_token(request: Request, db: Session = Depends(get_db)):
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
headers={"WWW-Authenticate": "Bearer"},
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
if token in REVOKED_TOKENS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
jti = payload.get("jti")
if jti and db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
return {"message": "success", "data": payload}
@router.post("/login_sso", response_model=schemas.LoginResponse)
async def login_sso(
login_req: schemas.LoginSSORequest,
db: Session = Depends(get_db),
):
server = os.getenv("RADIUS_SERVER")
secret = os.getenv("RADIUS_SECRET")
port = int(os.getenv("RADIUS_PORT", 1812))
realm = os.getenv("RADIUS_REALM", "")
if not server or not secret:
raise HTTPException(status_code=500, detail="Configuration is missing")
try:
local_part = (login_req.email or "").split("@")[0]
except Exception:
local_part = login_req.email or ""
username = f"{local_part}{realm}"
radius_password = f"{login_req.password}{login_req.otp or ''}"
ok = check_radius_login(server, port, secret, username, radius_password)
if not ok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password or OTP")
user = crud.get_user_by_email(db, login_req.email)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found. Please contact to Admin.")
if int(getattr(user, "status", 0) or 0) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not active",
headers={"WWW-Authenticate": "Bearer"},
)
user_details = crud.get_user_with_details(db, user)
token_data = {
"sub": str(user.id),
"email": user.email,
"fullname": user.fullname,
"unit_id": user.unit_id,
"status": user.status,
}
access_token = create_access_token(data=token_data)
try:
payload = decode_access_token(access_token)
jti = payload.get("jti")
if jti:
set_latest_jti(int(user.id), jti)
except Exception:
pass
return {
"access_token": access_token,
"token_type": "bearer",
"user": user_details,
}
@router.get("/login_sso_vnpt", response_model=schemas.LoginResponse)
async def login_sso_vnpt(
ticket: str = Query(..., description="CAS ticket"),
db: Session = Depends(get_db),
):
# ===== 1. CAS CONFIG =====
CAS_HOST = os.getenv("CAS_HOST") # vd: https://cas.vnpt.vn/cas
SERVICE_URL = os.getenv("CAS_SERVICE_URL")
if not CAS_HOST or not SERVICE_URL:
raise HTTPException(
status_code=500,
detail="CAS configuration missing",
)
# ===== 2. BUILD VALIDATE URL =====
validate_url = (
f"{CAS_HOST.rstrip('/')}/serviceValidate"
f"?ticket={ticket}&service={SERVICE_URL}"
)
print(validate_url)
# ===== 3. CALL CAS =====
try:
resp = requests.get(validate_url, timeout=5)
resp.raise_for_status()
except Exception:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="CAS validate service unavailable",
)
# ===== 4. PARSE XML =====
try:
root = ET.fromstring(resp.text)
except ET.ParseError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid CAS response",
)
namespace = {"cas": "http://www.yale.edu/tp/cas"}
user_node = root.find(".//cas:user", namespace)
if user_node is None or not user_node.text:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="CAS returned but refused to validate your identity",
)
# ===== 5. NETID → EMAIL =====
netid = user_node.text.strip()
email = f"{netid.lower()}@vnpt.vn"
# ===== 6. CHECK USER DB =====
user = crud.get_user_by_email(db, email)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found. Please contact to Admin.",
)
if int(getattr(user, "status", 0) or 0) != 2:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not active",
headers={"WWW-Authenticate": "Bearer"},
)
# ===== 7. USER DETAILS =====
user_details = crud.get_user_with_details(db, user)
# ===== 8. TOKEN DATA (GIỮ NGUYÊN THEO YÊU CẦU) =====
token_data = {
"sub": str(user.id),
"email": user.email,
"fullname": user.fullname,
"unit_id": user.unit_id,
"status": user.status,
}
access_token = create_access_token(data=token_data)
try:
payload = decode_access_token(access_token)
jti = payload.get("jti")
if jti:
set_latest_jti(int(user.id), jti)
except Exception:
pass
# ===== 9. RESPONSE =====
return {
"access_token": access_token,
"token_type": "bearer",
"user": user_details,
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import os
from urllib.parse import quote_plus
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
import datetime
from sqlalchemy import inspect as sa_inspect
from .utils import audit_log_path, get_current_user_ctx
load_dotenv()
# Prefer per-component DB config when provided; fallback to DATABASE_URL; finally to sqlite for dev
db_driver = os.getenv("DB_DRIVER")
db_user = os.getenv("DB_USER")
db_password = os.getenv("DB_PASSWORD")
db_host = os.getenv("DB_HOST")
db_port = os.getenv("DB_PORT")
db_name = os.getenv("DB_NAME")
database_url = None
if db_driver and db_user and db_host and db_port and db_name is not None:
# URL-encode username and password. If password is empty or None, omit the ':' portion
user_enc = quote_plus(db_user)
if db_password:
pwd_enc = quote_plus(db_password)
auth = f"{user_enc}:{pwd_enc}"
else:
auth = f"{user_enc}"
database_url = f"{db_driver}://{auth}@{db_host}:{db_port}/{db_name}"
if not database_url:
database_url = os.getenv("DATABASE_URL")
if not database_url:
# final fallback for local dev
database_url = "sqlite:///./test.db"
# Engine pool configuration to avoid QueuePool timeouts under concurrency
default_pool_size = int(os.getenv("DB_POOL_SIZE", "20"))
default_max_overflow = int(os.getenv("DB_POOL_MAX_OVERFLOW", "40"))
default_pool_timeout = int(os.getenv("DB_POOL_TIMEOUT", "60"))
default_pool_recycle = int(os.getenv("DB_POOL_RECYCLE", "1800")) # seconds
if database_url.startswith("sqlite"):
engine = create_engine(
database_url,
pool_pre_ping=True,
connect_args={"check_same_thread": False},
)
else:
engine = create_engine(
database_url,
pool_pre_ping=True,
pool_size=default_pool_size,
max_overflow=default_max_overflow,
pool_timeout=default_pool_timeout,
pool_recycle=default_pool_recycle,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def _write_audit(line: str) -> None:
try:
with open(audit_log_path(), "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
@event.listens_for(SessionLocal, "after_flush")
def _audit_after_flush(session, context):
user = get_current_user_ctx() or {}
uid = str((user.get("sub") or user.get("id") or "-"))
email = user.get("email") or "-"
now = datetime.datetime.utcnow().isoformat()
for obj in list(session.new):
ins = sa_inspect(obj)
mapper = ins.mapper
table = mapper.local_table.name if getattr(mapper, "local_table", None) is not None else getattr(obj, "__tablename__", obj.__class__.__name__)
pk = ins.identity
line = f"{now} | {uid} | {email} | INSERT {table} | pk={pk}\n"
_write_audit(line)
for obj in list(session.dirty):
if not session.is_modified(obj, include_collections=False):
continue
ins = sa_inspect(obj)
mapper = ins.mapper
table = mapper.local_table.name if getattr(mapper, "local_table", None) is not None else getattr(obj, "__tablename__", obj.__class__.__name__)
changes = []
for attr in ins.attrs:
hist = attr.history
if hist.has_changes():
old = hist.deleted[0] if hist.deleted else None
new = hist.added[0] if hist.added else getattr(obj, attr.key)
changes.append(f"{attr.key}={old}->{new}")
pk = ins.identity
chs = ", ".join(changes) if changes else "-"
line = f"{now} | {uid} | {email} | UPDATE {table} | pk={pk} | {chs}\n"
_write_audit(line)
for obj in list(session.deleted):
ins = sa_inspect(obj)
mapper = ins.mapper
table = mapper.local_table.name if getattr(mapper, "local_table", None) is not None else getattr(obj, "__tablename__", obj.__class__.__name__)
pk = ins.identity
line = f"{now} | {uid} | {email} | DELETE {table} | pk={pk}\n"
_write_audit(line)
+301
View File
@@ -0,0 +1,301 @@
import os
import time
import datetime
from pathlib import Path
from fastapi import FastAPI, Request, Depends, HTTPException, status
from sqlalchemy.orm import Session
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from dotenv import load_dotenv
from .utils import decode_access_token, set_current_user_ctx
from .database import get_db
from . import models
from typing import Dict, Any, List, Optional
load_dotenv()
from .database import engine, Base
from .routers import auth, users, roles, posts, month, security_index
from .routers import hardening
from .routers import files
from app.routers import system_groups as system_groups_router
from app.routers import systems as systems_router
app = FastAPI(
title="ANTT Portal API",
swagger_ui_parameters={"persistAuthorization": True},
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# @app.middleware("http")
# async def docs_ip_whitelist(request: Request, call_next):
# path = request.url.path
# if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi.json"):
# wl = [x.strip() for x in (os.getenv("DOCS_WHITELIST_IPS") or "").split(",") if x.strip()]
# client_ip = request.client.host if request.client else ""
# if wl and client_ip not in wl:
# raise HTTPException(status_code=403, detail="IP not allowed")
# return await call_next(request)
def get_real_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
if xff:
# Lấy IP đầu tiên (client thật)
return xff.split(",")[0].strip()
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
if request.client:
return request.client.host
return ""
@app.middleware("http")
async def docs_ip_whitelist(request: Request, call_next):
path = request.url.path
if path.startswith(("/docs", "/redoc", "/openapi.json")):
wl = [
ip.strip()
for ip in (os.getenv("DOCS_WHITELIST_IPS") or "").split(",")
if ip.strip()
]
client_ip = get_real_ip(request)
if wl and client_ip not in wl:
raise HTTPException(
status_code=403,
detail=f"IP {client_ip} not allowed"
)
return await call_next(request)
def _activity_log_dir() -> Path:
base = os.getenv("ACTIVITY_LOG_DIR", "logs")
p = Path(base).resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _activity_log_path() -> Path:
now = datetime.datetime.utcnow()
month_dir = _activity_log_dir() / now.strftime("%Y%m")
month_dir.mkdir(parents=True, exist_ok=True)
return month_dir / f"{now.strftime('%Y-%m-%d')}.txt"
@app.middleware("http")
async def activity_logger(request: Request, call_next):
path = request.url.path
if path.startswith("/auth/login"):
return await call_next(request)
if path.startswith("/auth/login_sso"):
return await call_next(request)
method = request.method.upper()
qs = request.url.query or ""
ip = request.client.host if request.client else "-"
start = time.time()
status_code = 500
user_payload = None
auth_header = request.headers.get("Authorization") or ""
token = None
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
token = None
except Exception:
token = None
if token:
try:
user_payload = decode_access_token(token)
except Exception:
user_payload = None
try:
set_current_user_ctx(user_payload)
except Exception:
pass
try:
response = await call_next(request)
status_code = response.status_code
return response
finally:
try:
set_current_user_ctx(None)
except Exception:
pass
try:
user_id = "-"
user_email = "-"
if user_payload:
try:
user_id = str(user_payload.get("sub") or "-")
user_email = user_payload.get("email") or "-"
except Exception:
pass
duration_ms = int((time.time() - start) * 1000)
line = f"{datetime.datetime.utcnow().isoformat()} | {ip} | {user_id} | {user_email} | {method} {path}{('?' + qs) if qs else ''} | {status_code} | {duration_ms}ms\n"
with open(_activity_log_path(), "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
@app.on_event("startup")
def on_startup():
# create tables if AUTO_CREATE_DB is enabled (convenience for dev only)
if os.getenv("AUTO_CREATE_DB", "0") == "1":
Base.metadata.create_all(bind=engine)
app.include_router(auth.router)
app.include_router(roles.router, prefix="/roles", tags=["roles"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(posts.router, prefix="/posts", tags=["posts"])
app.include_router(month.router, prefix="/months", tags=["months"])
app.include_router(security_index.router)
app.include_router(hardening.router)
# Đăng ký categories
from .routers import categories
app.include_router(categories.router, prefix="/categories", tags=["categories"])
app.include_router(files.router, prefix="/files", tags=["files"])
from .routers import pentest
from .routers import pentest_service
from .routers import jira
from .routers import units
from .routers import overview
from .routers import scorecard
from .routers import email as email_router
from .routers import documents as documents_router
from .routers import soc_ticket
from .routers import logsource
app.include_router(pentest.router, prefix="/pentest", tags=["pentest"])
app.include_router(pentest_service.router)
app.include_router(soc_ticket.router)
app.include_router(logsource.router)
from .routers import mail_history
app.include_router(mail_history.router, prefix="/mail-history", tags=["mail-history"])
from .routers import import_history as import_history_router
app.include_router(import_history_router.router, prefix="/import-history", tags=["import-history"])
from .routers import manage_systems as manage_systems_router
app.include_router(manage_systems_router.router)
# app.include_router(system_groups_router.router)
app.include_router(systems_router.router)
app.include_router(jira.router, prefix="/jira", tags=["jira"])
app.include_router(units.router)
app.include_router(overview.router)
app.include_router(scorecard.router)
app.include_router(email_router.email_roles_router)
app.include_router(email_router.email_users_router)
app.include_router(documents_router.router)
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title="ANTT Portal API",
version="1.0.0",
description="API Gateway for ANTT Portal",
routes=app.routes,
)
openapi_schema["components"]["securitySchemes"] = {
"Bearer": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Enter JWT token (no 'Bearer' prefix needed)",
}
}
# Áp dụng security toàn cục để UI biết cần Bearer
openapi_schema["security"] = [{"Bearer": []}]
# Add security requirement to all paths except /auth/login
if "paths" in openapi_schema:
for path, path_item in openapi_schema["paths"].items():
# Bỏ qua các endpoint auth
if "/auth/" in path:
continue
# Thêm security cho từng operation nếu chưa có
for method in ["get", "post", "put", "delete", "patch"]:
if method in path_item:
operation = path_item[method]
if "security" not in operation:
operation["security"] = [{"Bearer": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
# Public API: system groups tree via ManageSystem (levels 0/1/2), API key + IP whitelist
@app.post("/sync/system-groups", tags=["system_groups"])
def system_groups_tree(
request: Request,
payload: Dict[str, Any],
db: Session = Depends(get_db),
):
cfg_key = os.getenv("SYSTEM_GROUPS_API_KEY")
if not cfg_key:
raise HTTPException(status_code=500, detail="SYSTEM_GROUPS_API_KEY is not configured")
use_key = str(payload.get("api_key") or "")
if (use_key or "").strip() != cfg_key.strip():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "ApiKey"},
)
wl = [x.strip() for x in (os.getenv("SYSTEM_GROUPS_WHITELIST_IPS") or "").split(",") if x.strip()]
client_ip = request.client.host if request.client else ""
if wl and client_ip not in wl:
raise HTTPException(status_code=403, detail="IP not allowed")
items: List[models.ManageSystem] = db.query(models.ManageSystem).order_by(models.ManageSystem.level.asc(), models.ManageSystem.id.asc()).all()
type_map = {
0: "Web quản trị",
1: "Web dịch vụ",
2: "API",
3: "Mobile app",
4: "Khác",
5: "CNTT",
6: "ATTT",
}
nodes: Dict[int, Dict[str, Any]] = {}
for it in items:
tval = int(it.type) if getattr(it, "type", None) is not None else None
nodes[int(it.id)] = {
"id": int(it.id),
"name": it.name,
"level": int(it.level),
"parent_id": int(it.parent_id) if getattr(it, "parent_id", None) is not None else None,
"url_ip": getattr(it, "url_ip", None),
"unit_id": int(it.unit_id) if getattr(it, "unit_id", None) is not None else None,
"type": tval,
"type_name": type_map.get(tval) if tval is not None else None,
"private": bool(it.private) if getattr(it, "private", None) is not None else None,
"priority_level": int(it.priority_level) if getattr(it, "priority_level", None) is not None else None,
"status": bool(it.status) if getattr(it, "status", None) is not None else None,
"children": [],
}
roots: List[Dict[str, Any]] = []
for it in items:
node = nodes[int(it.id)]
pid = getattr(it, "parent_id", None)
if pid is None:
roots.append(node)
else:
parent = nodes.get(int(pid))
if parent:
parent["children"].append(node)
else:
roots.append(node)
return {"message": "success", "data": roots}
+541
View File
@@ -0,0 +1,541 @@
from sqlalchemy import Column, BigInteger, Integer, String, DateTime, Text, Boolean, Float, Index, UniqueConstraint
from .database import Base
import datetime
class Role(Base):
__tablename__ = "role"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(String(255))
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships
# relationships are intentionally omitted; handle joins in SQL queries as needed
class Permission(Base):
__tablename__ = "permission"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
path = Column(String(255), nullable=False)
method = Column(String(20), nullable=False)
description = Column(String(255))
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class Unit(Base):
__tablename__ = "units"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
__table_args__ = (Index('ix_units_name', 'name'),)
class User(Base):
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, unique=True)
fullname = Column(String(255), nullable=False)
unit_id = Column(BigInteger, nullable=True)
role_id = Column(BigInteger, nullable=True)
status = Column(Integer, default=1)
#status 1 is created, 2 is active, 3 is disabled
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
hashed_password = Column(String(255), nullable=True)
# relationships intentionally omitted
__table_args__ = (Index('ix_users_email', 'email'),)
class Category(Base):
__tablename__ = "category"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, unique=True)
# relationships intentionally omitted
class Post(Base):
__tablename__ = "posts"
id = Column(BigInteger, primary_key=True, autoincrement=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
title = Column(String(255), nullable=False)
created_by = Column(BigInteger)
category_id = Column(BigInteger)
status = Column(Boolean, default=True)
thumbnail = Column(String(255), nullable=True)
# relationships intentionally omitted
class Month(Base):
__tablename__ = "month"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
from_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
# relationships intentionally omitted
class NAC(Base):
__tablename__ = "nac"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=False)
total = Column(BigInteger, default=0)
installed = Column(BigInteger, default=0)
ignored = Column(BigInteger, default=0)
rate = Column(Float, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class SmartIR(Base):
__tablename__ = "smartir"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=False)
total = Column(BigInteger, default=0)
installed = Column(BigInteger, default=0)
new_install = Column(BigInteger, default=0)
ignored = Column(BigInteger, default=0)
rate = Column(Float, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class Compliance(Base):
__tablename__ = "compliance"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=False)
windows_key = Column(BigInteger, default=0)
office = Column(BigInteger, default=0)
ms17010 = Column(BigInteger, default=0)
firewall = Column(BigInteger, default=0)
uac = Column(BigInteger, default=0)
winrar = Column(BigInteger, default=0)
antivirus = Column(BigInteger, default=0)
update_win = Column(BigInteger, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class LogSource(Base):
__tablename__ = "logsource"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=False)
log_id = Column(BigInteger)
name = Column(String(255), nullable=False)
description = Column(Text)
is_enabled = Column(Boolean, default=True)
source_type_id = Column(BigInteger)
status = Column(Integer) # lưu mã 1=OK, 2=ERROR, 3=DISABLE, 4=NOT AVAILABLE
message = Column(String(255))
other = Column(String(255))
file_id = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class LogSourceComment(Base):
__tablename__ = "logsource_comment"
id = Column(BigInteger, primary_key=True, autoincrement=True)
logsource_id = Column(BigInteger, nullable=False)
user_id = Column(BigInteger, nullable=False)
comment = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class SourceType(Base):
__tablename__ = "source_type"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
type = Column(String(100))
# relationships intentionally omitted
class Ticket(Base):
__tablename__ = "ticket"
id = Column(BigInteger, primary_key=True, autoincrement=True)
open_count = Column(BigInteger, default=0)
in_process = Column(BigInteger, default=0)
completed = Column(BigInteger, default=0)
unit_id = Column(BigInteger, nullable=False)
month_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class RootAccess(Base):
__tablename__ = "root_access"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=False)
number = Column(BigInteger, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class AttackStatics(Base):
__tablename__ = "attack_statics"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
siem_fintech = Column(BigInteger, default=0)
siem_media = Column(BigInteger, default=0)
ticket = Column(BigInteger, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class SmartIRDetail(Base):
__tablename__ = "smartir_detail"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=True)
vnpt_ma_nhan_vien = Column(String(255), nullable=True)
name = Column(String(255), nullable=True)
email = Column(String(255), nullable=True)
phong_ban = Column(String(255), nullable=True)
ip = Column(String(255), nullable=True)
pc_name = Column(String(255), nullable=True)
mac = Column(String(255), nullable=True)
agent_version = Column(String(255), nullable=True)
last_online = Column(String(255), nullable=True)
status = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class NACDetail(Base):
__tablename__ = "nac_detail"
id = Column(BigInteger, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=True)
name = Column(String(255), nullable=True)
email = Column(String(255), nullable=True)
status = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class ComplianceDetail(Base):
__tablename__ = "compliance_detail"
id = Column(BigInteger, primary_key=True, autoincrement=True)
unit_id = Column(BigInteger, nullable=True)
month_id = Column(BigInteger, nullable=False)
vnpt_ma_nhan_vien = Column(String(255), nullable=True)
name = Column(String(255), nullable=True)
email = Column(String(255), nullable=True)
phong_ban = Column(String(255), nullable=True)
windows_key = Column(BigInteger, nullable=True)
office = Column(BigInteger, nullable=True)
ms17010 = Column(BigInteger, nullable=True)
firewall = Column(BigInteger, nullable=True)
uac = Column(BigInteger, nullable=True)
winrar = Column(BigInteger, nullable=True)
antivirus = Column(BigInteger, nullable=True)
update_win = Column(BigInteger, nullable=True)
status = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class ComplianceSmartIR(Base):
__tablename__ = "compliance_smartir"
id = Column(Integer, primary_key=True, autoincrement=True)
month_id = Column(BigInteger, nullable=False)
unit_id = Column(BigInteger, nullable=True)
vnpt_ma_nhan_vien = Column(String(20), nullable=False)
name = Column(String(255), nullable=False)
email = Column(String(255), nullable=False)
phong_ban = Column(String(255), nullable=True)
ten_may_tinh = Column(String(255), nullable=True)
mac = Column(String(50), nullable=True)
active_windows = Column(Integer, nullable=True)
bat_windows_firewall = Column(Integer, nullable=True)
bat_uac = Column(Integer, nullable=True)
cai_winrar_ban_moi = Column(Integer, nullable=True)
bat_av_fullscan = Column(Integer, nullable=True)
update_windows = Column(Integer, nullable=True)
status = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
class MailHistory(Base):
__tablename__ = "mail_history"
id = Column(BigInteger, primary_key=True, autoincrement=True)
post_id = Column(BigInteger)
subject = Column(String(255), nullable=True)
role_id = Column(BigInteger, nullable=True)
content = Column(Text)
status = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
# relationships intentionally omitted
class FileUpload(Base):
__tablename__ = "file_upload"
id = Column(BigInteger, primary_key=True, autoincrement=True)
path = Column(String(255), nullable=False)
table_name = Column(String(255), nullable=False)
table_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
__table_args__ = (Index('ix_fileupload_table', 'table_name', 'table_id'),)
class ImportHistory(Base):
__tablename__ = "import_history"
id = Column(BigInteger, primary_key=True, autoincrement=True)
type = Column(String(255), nullable=False)
file_path = Column(String(255), nullable=False)
month_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
__table_args__ = (Index('ix_import_history_type_month', 'type', 'month_id'),)
class MMFintech(Base):
__tablename__ = "mm_fintech"
id = Column(BigInteger, primary_key=True, autoincrement=True, index=True)
month_id = Column(BigInteger, nullable=False, index=True)
unit_id = Column(BigInteger, nullable=True, index=True)
file_path = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class ConvertHardening(Base):
__tablename__ = "convert_hardening"
id = Column(BigInteger, primary_key=True, autoincrement=True, index=True)
filename = Column(String(255), nullable=False, index=True)
user_id = Column(BigInteger, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class SystemGroup(Base):
__tablename__ = "system_group"
id = Column(BigInteger, primary_key=True, index=True, autoincrement=True)
name = Column(String(255), nullable=False, index=True)
description = Column(String(1000), nullable=True)
class System(Base):
__tablename__ = "system"
id = Column(BigInteger, primary_key=True, index=True, autoincrement=True)
name = Column(String(255), nullable=False, index=True)
url_ip = Column(String(255), nullable=True, index=True)
unit_id = Column(BigInteger, nullable=True, index=True)
system_group_id = Column(BigInteger, nullable=True, index=True)
description = Column(String(1000), nullable=True)
status = Column(Integer, nullable=True, index=True)
class ManageSystem(Base):
__tablename__ = "manage_system"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, unique=True)
level = Column(Integer, nullable=False)
url_ip = Column(String(255), nullable=True)
unit_id = Column(BigInteger, nullable=True)
parent_id = Column(BigInteger, nullable=True)
type = Column(Integer, nullable=True)
private = Column(Boolean, default=False)
priority_level = Column(Integer, nullable=True)
status = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
__table_args__ = (
Index('ix_manage_system_name', 'name'),
Index('ix_manage_system_level_parent', 'level', 'parent_id'),
Index('ix_manage_system_unit', 'unit_id'),
)
class PentestService(Base):
__tablename__ = "pentest_service"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(Text, nullable=True)
type = Column(Integer, nullable=False) # 1: Đánh giá ATTT, 2: Công việc phối hợp, 3: Phối hợp xử lý, 4: Khác
code = Column(String(50), nullable=False, unique=True)
file_path = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
due_date = Column(DateTime, nullable=True)
unit_id = Column(BigInteger, nullable=True)
status = Column(Integer, default=0) # 0: nháp, 1: tạo mới, 2: tiếp nhận, 3: đã xử lý, 4: đóng, 5: hủy
user_id = Column(BigInteger, nullable=True)
target_id = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class PentestServiceComment(Base):
__tablename__ = "pentest_service_comment"
id = Column(BigInteger, primary_key=True, autoincrement=True)
pentest_service_id = Column(BigInteger, nullable=False)
content = Column(Text, nullable=False)
user_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class PentestServiceHistory(Base):
__tablename__ = "pentest_service_history"
id = Column(BigInteger, primary_key=True, autoincrement=True)
pentest_service_id = Column(BigInteger, nullable=False)
old_status = Column(Integer, nullable=True)
new_status = Column(Integer, nullable=False)
user_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class PentestServiceAssign(Base):
__tablename__ = "pentest_service_assign"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=True)
email = Column(String(255), nullable=True)
pentest_service_id = Column(BigInteger, nullable=False)
class RevokedToken(Base):
__tablename__ = "revoked_token"
id = Column(BigInteger, primary_key=True, autoincrement=True)
jti = Column(String(64), nullable=False, unique=True, index=True)
user_id = Column(BigInteger, nullable=True)
revoked_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
expires_at = Column(DateTime, nullable=True)
class EmailRole(Base):
__tablename__ = "email_role"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
description = Column(String(1000), nullable=True)
class EmailUser(Base):
__tablename__ = "email_user"
id = Column(BigInteger, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False)
name = Column(String(255), nullable=True)
role_id = Column(BigInteger, nullable=False)
type = Column(Integer, nullable=False)
__table_args__ = (Index('ix_email_user_role', 'role_id'),)
class Document(Base):
__tablename__ = "documents"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
number_doc = Column(String(255), nullable=False)
sign_date = Column(DateTime, nullable=True)
level = Column(Integer, nullable=False)
validate_date = Column(DateTime, nullable=True)
file_path = Column(String(255), nullable=True)
class SecurityIndex(Base):
__tablename__ = "security_index"
id = Column(BigInteger, primary_key=True, autoincrement=True, index=True)
soc = Column(Integer, default=0)
pentest = Column(Integer, default=0)
access_policy = Column(Integer, default=0)
two_fa_policy = Column(Integer, default=0) # Cannot start with digit in Python, mapped to 2fa_policy if needed
config_network = Column(Integer, default=0)
config_server = Column(Integer, default=0)
patch_security = Column(Integer, default=0)
month_id = Column(BigInteger, nullable=False, index=True)
unit_id = Column(BigInteger, nullable=False, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class SocTarget(Base):
__tablename__ = "soc_target"
id = Column(BigInteger, primary_key=True, autoincrement=True)
unit_id = Column(BigInteger, nullable=False)
priority = Column(Integer, nullable=False) # 1=critical → 5=low
name = Column(String(255), nullable=False)
code = Column(String(100), unique=True)
status = Column(Integer, nullable=False, default=0) # 0:Open, 1:In Progress, 2:Resolved, 3:Closed
deadline = Column(DateTime)
description = Column(Text, nullable=False)
system_id = Column(BigInteger, nullable=True)
system_type = Column(Integer, nullable=True)
source_ip = Column(Text, nullable=True)
destination_ip = Column(Text, nullable=True)
soc_target_type_id = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
created_by = Column(BigInteger, nullable=True)
class SocTargetType(Base):
__tablename__ = "soc_target_type"
id = Column(BigInteger, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
class TicketRelated(Base):
__tablename__ = "ticket_related"
id = Column(BigInteger, primary_key=True, autoincrement=True)
soc_target_id = Column(BigInteger, nullable=False)
related_soc_target_id = Column(BigInteger, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class SocTargetAssign(Base):
__tablename__ = "soc_target_assign"
id = Column(BigInteger, primary_key=True, autoincrement=True)
target_id = Column(BigInteger, nullable=False)
user_id = Column(BigInteger, nullable=True)
email = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class SocTargetHistory(Base):
__tablename__ = "soc_target_history"
id = Column(BigInteger, primary_key=True, autoincrement=True)
target_id = Column(BigInteger, nullable=False)
field_name = Column(String(100), nullable=False)
old_value = Column(Text)
new_value = Column(Text)
changed_by = Column(BigInteger, nullable=True)
changed_at = Column(DateTime, default=datetime.datetime.utcnow)
class SocTargetComment(Base):
__tablename__ = "soc_target_comment"
id = Column(BigInteger, primary_key=True, autoincrement=True)
target_id = Column(BigInteger, nullable=False)
user_id = Column(BigInteger, nullable=True)
email = Column(String(255), nullable=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class SocTargetAttachment(Base):
__tablename__ = "soc_target_attachment"
id = Column(BigInteger, primary_key=True, autoincrement=True)
target_id = Column(BigInteger, nullable=False)
file_name = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
file_size = Column(BigInteger, nullable=True)
file_type = Column(String(100), nullable=True)
uploaded_by = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class PostComment(Base):
__tablename__ = "post_comment"
id = Column(BigInteger, primary_key=True, autoincrement=True)
post_id = Column(BigInteger, nullable=False)
user_id = Column(BigInteger, nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
+142
View File
@@ -0,0 +1,142 @@
import socket
import struct
import hashlib
import random
# RADIUS Attribute Type names (RFC 2865 + common)
RADIUS_ATTR_NAMES = {
1: "User-Name",
6: "Service-Type",
8: "Framed-IP-Address",
11: "Filter-Id",
18: "Reply-Message",
25: "Class",
26: "Vendor-Specific",
27: "Session-Timeout",
}
def encrypt_password(password: str, secret: str, request_authenticator: bytes) -> bytes:
"""Mã hóa mật khẩu PAP theo chuẩn RADIUS RFC 2865."""
password_bytes = password.encode('utf-8')
if len(password_bytes) % 16 != 0:
password_bytes += b'\x00' * (16 - len(password_bytes) % 16)
encrypted = b''
last = secret.encode('utf-8') + request_authenticator
for i in range(0, len(password_bytes), 16):
block = password_bytes[i:i+16]
md5_hash = hashlib.md5(last).digest()
encrypted_block = bytes(a ^ b for a, b in zip(block, md5_hash))
encrypted += encrypted_block
last = secret.encode('utf-8') + encrypted_block
return encrypted
def build_access_request(username: str, password: str, secret: str,
identifier: int = None) -> bytes:
"""Tạo gói Access-Request PAP cho RADIUS server."""
if identifier is None:
identifier = random.randint(0, 255)
request_authenticator = bytes(random.getrandbits(8) for _ in range(16))
username_attr = b'\x01' + struct.pack('B', len(username) + 2) + username.encode('utf-8')
password_attr_bytes = encrypt_password(password, secret, request_authenticator)
password_attr = b'\x02' + struct.pack('B', len(password_attr_bytes) + 2) + password_attr_bytes
attrs = username_attr + password_attr
length = 20 + len(attrs)
header = struct.pack('!BBH', 1, identifier, length) + request_authenticator
return header + attrs
def parse_radius_attributes(attributes_raw: bytes) -> dict:
"""
Parse phần attributes của gói RADIUS response.
Trả về dict: {type_int: [value_bytes, ...]}
"""
result: dict[int, list] = {}
idx = 0
while idx < len(attributes_raw):
if idx + 2 > len(attributes_raw):
break
attr_type = attributes_raw[idx]
attr_len = attributes_raw[idx + 1]
if attr_len < 2 or idx + attr_len > len(attributes_raw):
break
attr_value = attributes_raw[idx + 2: idx + attr_len]
result.setdefault(attr_type, []).append(attr_value)
idx += attr_len
return result
def check_radius_login(server: str, port: int, secret: str, username: str,
password: str, timeout: int = 5) -> bool:
"""
Kiểm tra username/password với RADIUS server (PAP).
Trả về True nếu Access-Accept, False nếu thất bại.
(Giữ nguyên signature để không ảnh hưởng code cũ)
"""
ok, _ = check_radius_login_extended(server, port, secret, username, password, timeout)
return ok
def check_radius_login_extended(server: str, port: int, secret: str, username: str,
password: str, timeout: int = 5) -> tuple[bool, dict]:
"""
Kiểm tra username/password với RADIUS server (PAP).
Trả về (success: bool, attributes: dict)
attributes chứa các giá trị decode từ RADIUS response, ví dụ:
{
'filter_id': ['Trung tâm An ninh thông tin'],
'reply_message': ['privacyIDEA access granted'],
'raw': {11: [b'...'], 18: [b'...']},
}
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
attrs_decoded: dict = {}
try:
req_packet = build_access_request(username, password, secret)
sock.sendto(req_packet, (server, port))
resp, _ = sock.recvfrom(4096)
code = resp[0]
length = struct.unpack('!H', resp[2:4])[0]
raw_attrs = parse_radius_attributes(resp[20:length])
# Decode các attributes thường gặp sang chuỗi
def _decode_list(raw_list: list) -> list[str]:
return [v.decode('utf-8', errors='replace') for v in raw_list]
attrs_decoded['raw'] = raw_attrs
# Filter-Id (Type 11) — thường chứa thông tin nhóm/đơn vị
if 11 in raw_attrs:
attrs_decoded['filter_id'] = _decode_list(raw_attrs[11])
# Reply-Message (Type 18)
if 18 in raw_attrs:
attrs_decoded['reply_message'] = _decode_list(raw_attrs[18])
# Class (Type 25)
if 25 in raw_attrs:
attrs_decoded['class'] = _decode_list(raw_attrs[25])
if code == 2: # Access-Accept
print(f"[RADIUS] ✅ Đăng nhập thành công: {username}")
if attrs_decoded.get('filter_id'):
print(f"[RADIUS] Filter-Id: {attrs_decoded['filter_id']}")
return True, attrs_decoded
else:
print(f"[RADIUS] ❌ Sai username hoặc password: {username}")
return False, attrs_decoded
except socket.timeout:
print("[RADIUS] ⏱ Timeout khi kết nối đến RADIUS server.")
return False, {}
except Exception as e:
print(f"[RADIUS] ⚠️ Lỗi: {e}")
return False, {}
finally:
sock.close()
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
"""JWT authentication dependencies for FastAPI."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer
from starlette.requests import Request
from app.utils import decode_access_token
import jwt
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import RevokedToken, User
import re
import datetime
security = HTTPBearer()
# In-memory revoked token store (non-persistent). Use DB for production.
# In-memory fallback (không bền vững). Giữ lại để chặn ngay trong tiến trình hiện tại.
REVOKED_TOKENS: set[str] = set()
LATEST_JTI_PER_USER: dict[int, str] = {}
def revoke_token(token: str) -> None:
REVOKED_TOKENS.add(token)
def set_latest_jti(user_id: int, jti: str) -> None:
LATEST_JTI_PER_USER[user_id] = jti
def revoke_token_db(db: Session, token: str) -> None:
"""Decode token and persist its jti to DB for revocation."""
payload = decode_access_token(token)
jti = payload.get("jti")
sub = payload.get("sub")
exp = payload.get("exp")
expires_at = None
if isinstance(exp, (int, float)):
expires_at = datetime.datetime.utcfromtimestamp(exp)
elif isinstance(exp, datetime.datetime):
expires_at = exp
if not jti:
# không có jti thì vẫn dùng in-memory để tránh bỏ sót
REVOKED_TOKENS.add(token)
return
if not db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
db.add(RevokedToken(
jti=jti,
user_id=int(sub) if sub is not None else None,
expires_at=expires_at
))
db.commit()
async def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Dependency to extract and validate JWT token from request headers.
Returns the decoded token payload with user info.
Raises:
HTTPException: If token is invalid, expired, or missing
"""
auth_header = request.headers.get("Authorization")
if not auth_header:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
try:
scheme, token = auth_header.split()
if scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication scheme",
headers={"WWW-Authenticate": "Bearer"},
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
# Reject revoked tokens (in-memory fallback)
if token in REVOKED_TOKENS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
# Check DB revocation via jti
jti = payload.get("jti")
if jti and db.query(RevokedToken).filter(RevokedToken.jti == jti).first():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
uid_int = int(user_id)
latest = LATEST_JTI_PER_USER.get(uid_int)
if latest and jti and jti != latest:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is not the latest",
headers={"WWW-Authenticate": "Bearer"},
)
u = db.query(User).filter(User.id == uid_int).first()
if u:
if payload.get("role_id") is None:
payload["role_id"] = getattr(u, "role_id", None)
if payload.get("unit_id") is None:
payload["unit_id"] = getattr(u, "unit_id", None)
if not payload.get("email"):
payload["email"] = getattr(u, "email", None)
if not payload.get("fullname"):
payload["fullname"] = getattr(u, "fullname", None)
if payload.get("status") is None:
payload["status"] = getattr(u, "status", None)
except HTTPException:
raise
except Exception:
pass
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
# ---- RBAC helpers ----
# Role IDs
ROLE_ADMIN = 1
ROLE_DIRECTOR = 2
ROLE_MANAGER = 3
ROLE_LEADER_PM = 4
ROLE_ADMIN_LIMITED = 9
def _role_id(user: dict) -> int | None:
try:
rid = user.get("role_id")
return int(rid) if rid is not None else None
except Exception:
return None
def is_admin(user: dict) -> bool:
return _role_id(user) in (ROLE_ADMIN, ROLE_ADMIN_LIMITED)
def is_director(user: dict) -> bool:
return _role_id(user) == ROLE_DIRECTOR
def is_manager(user: dict) -> bool:
return _role_id(user) == ROLE_MANAGER
def is_leader_pm(user: dict) -> bool:
return _role_id(user) == ROLE_LEADER_PM
def get_user_email(user: dict) -> str:
return (user.get("email") or "").strip()
def get_user_unit_id(user: dict) -> int | None:
try:
uid = user.get("unit_id")
return int(uid) if uid is not None else None
except Exception:
return None
# RBAC
ROLE_ADMIN = 1
ROLE_DIRECTOR = 2
ROLE_MANAGER = 3
ROLE_LEADER_PM = 4
ROLE_ADMIN_LIMITED = 9
def _role_id(user: dict) -> int | None:
try:
rid = user.get("role_id")
return int(rid) if rid is not None else None
except Exception:
return None
# def is_admin(user: dict) -> bool:
# return _role_id(user) in (ROLE_ADMIN, ROLE_ADMIN_LIMITED)
# def is_director(user: dict) -> bool:
# return _role_id(user) == ROLE_DIRECTOR
# def is_manager(user: dict) -> bool:
# return _role_id(user) == ROLE_MANAGER
# def is_leader_pm(user: dict) -> bool:
# return _role_id(user) == ROLE_LEADER_PM
# def get_user_email(user: dict) -> str:
# return (user.get("email") or "").strip()
def _method_path(request: Request) -> tuple[str, str]:
return request.method.upper(), request.url.path.lower()
def _path_match(path: str, patterns: list[str]) -> bool:
for p in patterns:
q = p.lower()
if "*" in q:
regex = "^" + re.escape(q).replace("\\*", ".*") + "$"
if re.match(regex, path):
return True
else:
if path == q or path.startswith(q + "/"):
return True
return False
DIRECTOR_GET = [
"/months",
"/units",
"/jira/tickets-overview",
"/scorecard*",
"/overview/nac",
"/overview/compliance",
"/months/*/compliance",
"/overview/smart-ir",
"/pentest/get-overview",
"/pentest/vuln-lastest-12month",
"/pentest/category",
"/pentest/category-vuln",
"/pentest/get-top-vulns",
"/pentest/get-target-detail",
"/pentest/download-target-checklist",
"/months/*/files",
"/files/download",
"/jira/tickets-week",
"/jira/tickets-12-month-lastest",
"/months/*/root-access",
"/months/*/attack-statics",
"/months/*/logsource",
"/months/*/nac-smartir",
"/months/*/compliance",
"/posts",
"/categories",
"/documents",
"/documents/download",
"/months/*/pam-tsc",
"/hardening/*",
"/pentest/log_history/*",
"/pentest-service*",
"/logsource/*/comments",
]
DIRECTOR_POST = [
"/auth/login",
"/auth/logout",
"/pentest-service*",
]
MANAGER_GET = DIRECTOR_GET + [
"/pentest/update-vuln-status",
]
MANAGER_POST = DIRECTOR_POST
LEADER_GET = MANAGER_GET
LEADER_POST = MANAGER_POST
def enforce_rbac(request: Request, user: dict = Depends(get_current_user)):
method, path = _method_path(request)
# Cho phép tất cả các nhóm người dùng đã đăng nhập truy cập vào /pentest-service
if "pentest-service" in path:
return
rid = _role_id(user)
if rid == ROLE_ADMIN:
return
if rid == ROLE_ADMIN_LIMITED:
deny_get = [
# "/pentest/category-vuln",
"/pentest/get-target-detail",
"/pentest/download-target-checklist",
]
deny_post = [
"/users",
"/roles",
]
deny_put = [
"/users/*",
"/roles/*",
]
deny_delete = [
"/users/*",
"/roles/*",
]
if method == "GET" and _path_match(path, deny_get):
raise HTTPException(status_code=403, detail="Forbidden")
if method == "POST" and _path_match(path, deny_post):
raise HTTPException(status_code=403, detail="Forbidden")
if method == "PUT" and _path_match(path, deny_put):
raise HTTPException(status_code=403, detail="Forbidden")
if method == "DELETE" and _path_match(path, deny_delete):
raise HTTPException(status_code=403, detail="Forbidden")
return
# Cho phép các method khác GET cho pentest-service
NONGET_ALLOWED_ALL_ROLES = [
"/systems/search-leak",
"/hardening/convert/upload",
"/pentest-service*",
"/posts/*/comments", # Cho phép tất cả người dùng đã đăng nhập thêm comment
"/logsource/*/comments", # Cho phép tất cả người dùng đã đăng nhập thêm bình luận LogSource
]
if method != "GET":
if _path_match(path, NONGET_ALLOWED_ALL_ROLES):
return
raise HTTPException(status_code=403, detail="Forbidden")
if is_director(user):
allowed = DIRECTOR_GET if method == "GET" else DIRECTOR_POST
elif is_manager(user):
allowed = MANAGER_GET if method == "GET" else MANAGER_POST
elif is_leader_pm(user):
allowed = LEADER_GET if method == "GET" else LEADER_POST
else:
# Nếu không thuộc các nhóm trên nhưng là /pentest-service thì đã return ở trên
raise HTTPException(status_code=403, detail="Forbidden")
if not _path_match(path, allowed):
raise HTTPException(status_code=403, detail="Forbidden")
return
+90
View File
@@ -0,0 +1,90 @@
from passlib.context import CryptContext
import hashlib
import jwt
from datetime import datetime, timedelta
from typing import Dict, Any
import os
import uuid
from pathlib import Path
import contextvars
# Password hashing
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash a plain-text password using PBKDF2-SHA256.
This supports arbitrary password lengths and avoids backend problems.
"""
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# return True
# JWT configuration
JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-this-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", 24))
def create_access_token(data: Dict[str, Any], expires_delta: timedelta = None) -> str:
"""Create a JWT access token.
Args:
data: Dictionary of claims to encode
expires_delta: Optional timedelta for token expiration; defaults to JWT_EXPIRATION_HOURS
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
to_encode.update({"exp": expire, "jti": uuid.uuid4().hex, "iat": datetime.utcnow()})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Dict[str, Any]:
"""Decode and verify a JWT access token.
Args:
token: JWT token string
Returns:
Decoded payload dictionary
Raises:
jwt.InvalidTokenError: If token is invalid or expired
"""
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
def audit_log_path() -> str:
base = os.getenv("AUDIT_LOG_DIR", "audit_logs")
p = Path(base).resolve()
p.mkdir(parents=True, exist_ok=True)
now = datetime.utcnow()
month_dir = p / now.strftime("%Y%m")
month_dir.mkdir(parents=True, exist_ok=True)
return str(month_dir / f"{now.strftime('%Y-%m-%d')}.txt")
# Per-request user context for audit logging
CURRENT_USER_CTX: contextvars.ContextVar = contextvars.ContextVar("CURRENT_USER_CTX", default=None)
def set_current_user_ctx(user: dict | None) -> None:
try:
CURRENT_USER_CTX.set(user)
except Exception:
pass
def get_current_user_ctx() -> dict | None:
try:
return CURRENT_USER_CTX.get()
except Exception:
return None
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
import os
# Create SQLite database in the app directory (mapped volume in Docker)
DB_FILE = os.path.join(os.path.dirname(__file__), "audit_data.db")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_FILE}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+805
View File
@@ -0,0 +1,805 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Email Notification Module for Audit Hardening Tool
Gửi email thông báo khi xử lý file hoàn tất
Hỗ trợ 2 phương thức: SMTP trực tiếp hoặc HTTP API
"""
import smtplib
import imaplib
import ssl
import os
import json
import base64
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.utils import formatdate
from datetime import datetime
from typing import List, Optional
from pathlib import Path
from colorama import Fore
class EmailNotifier:
"""Class xử lý gửi email thông báo"""
def __init__(self, config_path: str = "email_config.json"):
"""
Khởi tạo EmailNotifier với cấu hình từ file JSON
Args:
config_path: Đường dẫn tới file cấu hình email
"""
self.config = self._load_config(config_path)
self.enabled = self.config.get("enabled", False)
self.email_method = self.config.get("email_method", "smtp") # "smtp" hoặc "api"
def _load_config(self, config_path: str) -> dict:
"""Load cấu hình email từ file JSON"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
print(Fore.GREEN + f"[EMAIL] Configuration loaded. Enabled: {config.get('enabled', False)}" + Fore.RESET)
return config
except FileNotFoundError:
print(Fore.YELLOW + f"[EMAIL] {config_path} not found. Email notifications disabled." + Fore.RESET)
return {"enabled": False}
except Exception as e:
print(Fore.RED + f"[EMAIL] Error loading config: {e}" + Fore.RESET)
return {"enabled": False}
def _create_email(
self,
to_emails: List[str],
subject: str,
body_html: str,
body_text: str = None,
attachments: List[str] = None
) -> MIMEMultipart:
"""
Tạo đối tượng email
Args:
to_emails: Danh sách email nhận
subject: Tiêu đề email
body_html: Nội dung HTML
body_text: Nội dung text thuần (fallback)
attachments: Danh sách file đính kèm
Returns:
MIMEMultipart object
"""
msg = MIMEMultipart('alternative')
msg['From'] = self.config.get('sender_email', '')
msg['To'] = ', '.join(to_emails)
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
# Thêm phần text và HTML
if body_text:
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
# Thêm file đính kèm nếu có
if attachments and self.config.get('send_attachment', False):
for file_path in attachments:
if os.path.exists(file_path):
try:
with open(file_path, 'rb') as f:
part = MIMEBase('application', 'octet-stream')
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename="{os.path.basename(file_path)}"'
)
msg.attach(part)
except Exception as e:
print(Fore.YELLOW + f"[EMAIL] Cannot attach file {file_path}: {e}" + Fore.RESET)
return msg
def _file_to_base64(self, file_path: str) -> dict:
"""
Convert file thành base64 để gửi qua API
Args:
file_path: Đường dẫn tới file
Returns:
Dict với filename và content_base64
"""
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
p = Path(file_path)
size = p.stat().st_size
if size > MAX_FILE_SIZE:
print(Fore.YELLOW + f"[EMAIL] File too large for API attachment: {p.name} ({size} bytes)" + Fore.RESET)
raise ValueError(f"{p.name} too large ({size} bytes > {MAX_FILE_SIZE} bytes)")
with p.open("rb") as f:
return {
"filename": p.name,
"content_base64": base64.b64encode(f.read()).decode()
}
def _send_via_api(
self,
to_emails: List[str],
subject: str,
html_content: str,
attachments: List[str] = None
) -> bool:
"""
Gửi email qua HTTP API (giống send_mail.py)
Args:
to_emails: Danh sách email nhận
subject: Tiêu đề email
html_content: Nội dung HTML
attachments: Danh sách đường dẫn file đính kèm
Returns:
True nếu gửi thành công
"""
api_url = self.config.get('email_api_url')
api_key = self.config.get('email_api_key')
if not api_url or not api_key:
print(Fore.RED + "[EMAIL] ✗ API URL or API Key not configured in email_config.json" + Fore.RESET)
return False
# Chuẩn bị attachments base64
att_list = []
if attachments and self.config.get('send_attachment', False):
for file_path in attachments:
if os.path.exists(file_path):
try:
att_list.append(self._file_to_base64(file_path))
except Exception as e:
print(Fore.YELLOW + f"[EMAIL] Cannot attach file {file_path}: {e}" + Fore.RESET)
payload = {
"api_key": api_key,
"to_emails": to_emails,
"cc_emails": [],
"subject": subject,
"html_content": html_content,
"attachments": att_list
}
try:
print(Fore.CYAN + f"[EMAIL] Sending via API: {api_url}" + Fore.RESET)
r = requests.post(
api_url,
json=payload,
timeout=120,
verify=False
)
if not r.ok:
print(Fore.RED + f"[EMAIL] ✗ API Error: {r.status_code} - {r.text}" + Fore.RESET)
return False
print(Fore.GREEN + f"[EMAIL] ✓ API Email sent successfully to: {', '.join(to_emails)}" + Fore.RESET)
return True
except Exception as e:
print(Fore.RED + f"[EMAIL] ✗ API Request failed: {e}" + Fore.RESET)
return False
def _save_to_sent_folder(self, msg: MIMEMultipart) -> bool:
"""Lưu email vào thư mục Sent qua IMAP"""
if not self.config.get('save_to_sent', False):
return True
try:
ssl_context = ssl.create_default_context()
imap = imaplib.IMAP4_SSL(
host=self.config.get('imap_server', 'email.vnpt.vn'),
port=self.config.get('imap_port', 993),
ssl_context=ssl_context
)
imap.login(
self.config.get('sender_email', ''),
self.config.get('sender_password', '')
)
# Thử các tên thư mục Sent phổ biến
sent_folders = ['Sent', 'INBOX.Sent', 'Sent Items', 'Sent Messages']
for folder in sent_folders:
try:
status, _ = imap.select(folder)
if status == 'OK':
imap.append(folder, '\\Seen', None, msg.as_bytes())
print(Fore.CYAN + f"[EMAIL] Saved to {folder} folder" + Fore.RESET)
imap.logout()
return True
except:
continue
imap.logout()
return False
except Exception as e:
print(Fore.YELLOW + f"[EMAIL] Could not save to Sent: {e}" + Fore.RESET)
return False
def send_processing_notification(
self,
output_files: List[str],
os_type: str,
processing_time: float = None,
recipients: List[str] = None,
base_url: str = "http://localhost:8000",
system_info_list: List[dict] = None,
attachments: List[str] = None,
is_admin: bool = False
) -> bool:
"""
Gửi email thông báo khi xử lý file hoàn tất
Args:
output_files: Danh sách tên file output đã tạo
os_type: Loại hệ điều hành đã xử lý
processing_time: Thời gian xử lý (giây)
recipients: Danh sách email nhận (mặc định từ config)
base_url: URL base của ứng dụng
system_info_list: Danh sách thông tin hệ thống từ các file đã xử lý
attachments: Danh sách file đính kèm (đầy đủ đường dẫn)
is_admin: True nếu admin đăng nhập, sẽ gửi kèm file
Returns:
True nếu gửi thành công, False nếu lỗi
"""
if not self.enabled:
print(Fore.YELLOW + "[EMAIL] Email notifications are disabled" + Fore.RESET)
return False
# Sử dụng recipients mặc định nếu không được chỉ định
to_emails = recipients or self.config.get('default_recipients', [])
if not to_emails:
print(Fore.YELLOW + "[EMAIL] No recipients specified" + Fore.RESET)
return False
try:
# Tạo tiêu đề
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"[Audit Tool] Xử lý hoàn tất - {os_type.upper()} - {len(output_files)} file(s)"
# Tạo danh sách file với link download
files_html = ""
files_text = ""
for filename in output_files:
download_url = f"{base_url}/download/{filename}"
files_html += f'<li style="margin: 8px 0;"><a href="{download_url}" style="color: #1976d2; text-decoration: none;">📊 {filename}</a></li>\n'
files_text += f" - {filename}: {download_url}\n"
# Tạo nội dung thông tin hệ thống
system_info_html = ""
system_info_text = ""
if system_info_list:
for idx, system_info in enumerate(system_info_list):
if not system_info:
continue
# Tính màu compliance
compliance_pct = system_info.get('compliance_percentage', 0)
if compliance_pct >= 80:
compliance_color = "#4caf50"
elif compliance_pct >= 50:
compliance_color = "#ff9800"
else:
compliance_color = "#f44336"
# Script version badge
script_version_html = ''
if system_info.get('script_version'):
script_version_html = f'<span style="background: #e8f5e9; color: #2e7d32; padding: 4px 10px; border-radius: 15px; font-size: 11px; font-weight: 600; margin-left: 5px;">🏷️ Script v{system_info.get("script_version")}</span>'
# Mandatory/Optional stats
mandatory_passed = system_info.get('mandatory_passed', 0)
mandatory_total = system_info.get('mandatory_total', 0)
optional_passed = system_info.get('optional_passed', 0)
optional_total = system_info.get('optional_total', 0)
# Mandatory color
if mandatory_total > 0 and mandatory_passed == mandatory_total:
mandatory_color = "#4caf50"
elif mandatory_total > 0:
mandatory_color = "#ff9800"
else:
mandatory_color = "#666"
# Optional color
if optional_total > 0 and optional_passed == optional_total:
optional_color = "#4caf50"
elif optional_total > 0:
optional_color = "#ff9800"
else:
optional_color = "#666"
# Progress bar width
progress_width = int(compliance_pct * 2) # max 200px
system_info_html += f'''
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); border: 1px solid #e0e0e0; border-radius: 15px; padding: 25px; margin-bottom: 20px;">
<!-- Header with hostname and OS -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e3f2fd;">
<div>
<h3 style="color: #1565c0; margin: 0; font-size: 22px;">🖥️ {system_info.get('hostname', 'Unknown Host')}</h3>
<p style="color: #666; margin: 5px 0 0 0; font-size: 14px;">{system_info.get('os_name', 'Unknown OS')}</p>
</div>
<div style="text-align: right;">
<span style="background: #e3f2fd; color: #1565c0; padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;">
{system_info.get('audit_time', '')}
</span>
{script_version_html}
</div>
</div>
<!-- Info Grid - 2 columns -->
<table style="width: 100%; border-collapse: collapse;">
<tr>
<!-- Left Column: Basic Info + Hardware -->
<td style="vertical-align: top; width: 50%; padding-right: 10px;">
<!-- Basic Info -->
<div style="background: #fff; border-radius: 10px; padding: 15px; border: 1px solid #e8e8e8; margin-bottom: 10px;">
<h4 style="color: #1976d2; margin: 0 0 12px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">📋 Thông tin cơ bản</h4>
<table style="width: 100%; font-size: 12px;">
{f'<tr><td style="color: #666; padding: 4px 0;">IP Address:</td><td style="text-align: right;"><strong>{system_info.get("ip_address", "N/A")}</strong></td></tr>' if system_info.get('ip_address') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Kernel:</td><td style="text-align: right; font-size: 10px;"><strong>{system_info.get("kernel_version", "N/A")}</strong></td></tr>' if system_info.get('kernel_version') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Architecture:</td><td style="text-align: right;"><strong>{system_info.get("architecture", "N/A")}</strong></td></tr>' if system_info.get('architecture') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Uptime:</td><td style="text-align: right;"><strong>{system_info.get("uptime", "N/A")}</strong></td></tr>' if system_info.get('uptime') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Timezone:</td><td style="text-align: right;"><strong>{system_info.get("timezone", "N/A")}</strong></td></tr>' if system_info.get('timezone') else ''}
</table>
</div>
<!-- Hardware Info -->
<div style="background: #fff; border-radius: 10px; padding: 15px; border: 1px solid #e8e8e8;">
<h4 style="color: #4caf50; margin: 0 0 12px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">⚙️ Phần cứng</h4>
<table style="width: 100%; font-size: 12px;">
{f'<tr><td style="color: #666; padding: 4px 0;">CPU:</td><td style="text-align: right; font-size: 10px;"><strong>{system_info.get("cpu_model", "N/A")}</strong></td></tr>' if system_info.get('cpu_model') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">CPU Cores:</td><td style="text-align: right;"><strong>{system_info.get("cpu_cores", "N/A")}</strong></td></tr>' if system_info.get('cpu_cores') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Total Memory:</td><td style="text-align: right;"><strong>{system_info.get("total_memory", "N/A")}</strong></td></tr>' if system_info.get('total_memory') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Used Memory:</td><td style="text-align: right;"><strong>{system_info.get("used_memory", "N/A")}</strong></td></tr>' if system_info.get('used_memory') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Free Memory:</td><td style="text-align: right;"><strong>{system_info.get("free_memory", "N/A")}</strong></td></tr>' if system_info.get('free_memory') else ''}
</table>
</div>
</td>
<!-- Right Column: Compliance + Network -->
<td style="vertical-align: top; width: 50%; padding-left: 10px;">
<!-- Compliance Stats -->
<div style="background: #fff; border-radius: 10px; padding: 15px; border: 1px solid #e8e8e8; text-align: center;">
<h4 style="color: #e91e63; margin: 0 0 15px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">📊 Tỉ lệ tuân thủ</h4>
<!-- Compliance Percentage with progress bar -->
<div style="font-size: 36px; font-weight: bold; color: {compliance_color}; margin: 10px 0;">
{compliance_pct}%
</div>
<div style="background: #e0e0e0; border-radius: 10px; height: 10px; width: 100%; margin: 8px 0 15px 0;">
<div style="background: {compliance_color}; border-radius: 10px; height: 10px; width: {compliance_pct}%;"></div>
</div>
<!-- Mandatory / Non-Mandatory Stats -->
<table style="width: 100%; margin-top: 10px;">
<tr>
<td style="text-align: center; padding: 10px 8px; background: #e3f2fd; border-radius: 8px; width: 45%; border-left: 3px solid #1565c0;">
<div style="font-size: 10px; color: #1565c0; font-weight: 600; text-transform: uppercase; margin-bottom: 5px;">🔒 Bắt buộc đạt</div>
<div style="font-size: 20px; font-weight: bold; color: {mandatory_color};">{mandatory_passed}/{mandatory_total}</div>
{f'<div style="font-size: 11px; color: #999; margin-top: 4px;">{round((mandatory_passed / mandatory_total) * 100, 1) if mandatory_total > 0 else 0}%</div>' if mandatory_total > 0 else ''}
</td>
<td style="width: 10%;"></td>
<td style="text-align: center; padding: 10px 8px; background: #f3e5f5; border-radius: 8px; width: 45%; border-left: 3px solid #7b1fa2;">
<div style="font-size: 10px; color: #7b1fa2; font-weight: 600; text-transform: uppercase; margin-bottom: 5px;">📋 Tuỳ chọn đạt</div>
<div style="font-size: 20px; font-weight: bold; color: {optional_color};">{optional_passed}/{optional_total}</div>
</td>
</tr>
</table>
<!-- Total checks -->
<div style="margin-top: 10px; color: #666; font-size: 12px;">
Tổng số: <strong>{system_info.get('total_checks', 0)}</strong> tiêu chí
</div>
</div>
<!-- Network Info -->
<div style="background: #fff; border-radius: 10px; padding: 15px; border: 1px solid #e8e8e8; margin-top: 10px;">
<h4 style="color: #ff9800; margin: 0 0 12px 0; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">🌐 Mạng</h4>
<table style="width: 100%; font-size: 12px;">
{f'<tr><td style="color: #666; padding: 4px 0;">Interface:</td><td style="text-align: right;"><strong>{system_info.get("primary_interface", "N/A")}</strong></td></tr>' if system_info.get('primary_interface') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">MAC:</td><td style="text-align: right; font-size: 10px;"><strong>{system_info.get("mac_address", "N/A")}</strong></td></tr>' if system_info.get('mac_address') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">Gateway:</td><td style="text-align: right;"><strong>{system_info.get("default_gateway", "N/A")}</strong></td></tr>' if system_info.get('default_gateway') else ''}
{f'<tr><td style="color: #666; padding: 4px 0;">DNS:</td><td style="text-align: right;"><strong>{system_info.get("dns_servers", "N/A")}</strong></td></tr>' if system_info.get('dns_servers') else ''}
</table>
</div>
</td>
</tr>
</table>
</div>
'''
# Text version
script_ver_text = f" (Script v{system_info.get('script_version')})" if system_info.get('script_version') else ''
mandatory_pct_text = round((mandatory_passed / mandatory_total) * 100, 1) if mandatory_total > 0 else 0
system_info_text += f'''
--- Hệ thống {idx + 1}: {system_info.get('hostname', 'Unknown')}{script_ver_text} ---
OS: {system_info.get('os_name', 'N/A')}
IP: {system_info.get('ip_address', 'N/A')}
Kernel: {system_info.get('kernel_version', 'N/A')}
Architecture: {system_info.get('architecture', 'N/A')}
Uptime: {system_info.get('uptime', 'N/A')}
Timezone: {system_info.get('timezone', 'N/A')}
CPU: {system_info.get('cpu_model', 'N/A')} ({system_info.get('cpu_cores', 'N/A')} cores)
Memory: {system_info.get('total_memory', 'N/A')} (Used: {system_info.get('used_memory', 'N/A')}, Free: {system_info.get('free_memory', 'N/A')})
Interface: {system_info.get('primary_interface', 'N/A')} | MAC: {system_info.get('mac_address', 'N/A')}
Gateway: {system_info.get('default_gateway', 'N/A')} | DNS: {system_info.get('dns_servers', 'N/A')}
📊 Tỉ lệ tuân thủ: {compliance_pct}%
- 🔒 Bắt buộc đạt: {mandatory_passed}/{mandatory_total} ({mandatory_pct_text}%)
- 📋 Tuỳ chọn đạt: {optional_passed}/{optional_total}
- Tổng số: {system_info.get('total_checks', 0)} tiêu chí
'''
# ===== Tạo BẢNG TỔNG HỢP KẾT QUẢ (giống web) =====
summary_table_html = ""
if system_info_list and len([s for s in system_info_list if s]) > 0:
valid_infos = [s for s in system_info_list if s]
# Build summary table rows
summary_rows = ""
for idx_s, si in enumerate(valid_infos):
s_compliance = si.get('compliance_percentage', 0)
s_mandatory_passed = si.get('mandatory_passed', 0)
s_mandatory_total = si.get('mandatory_total', 0)
s_mandatory_failed = si.get('mandatory_failed', 0)
s_mandatory_pct = round((s_mandatory_passed / s_mandatory_total) * 100, 1) if s_mandatory_total > 0 else 0
# Compliance color
if s_compliance >= 80:
s_pct_bg = "#e8f5e9"; s_pct_color = "#2e7d32"
elif s_compliance >= 50:
s_pct_bg = "#fff3e0"; s_pct_color = "#e65100"
else:
s_pct_bg = "#ffebee"; s_pct_color = "#c62828"
# Mandatory color
if s_mandatory_failed == 0 and s_mandatory_total > 0:
s_mand_bg = "#e8f5e9"; s_mand_color = "#2e7d32"
elif s_mandatory_total > 0:
s_mand_bg = "#ffebee"; s_mand_color = "#c62828"
else:
s_mand_bg = "#f5f5f5"; s_mand_color = "#999"
# Note
if s_mandatory_failed > 0 and s_compliance < 80:
note_html = f'''
<div style="padding: 6px 10px; background: #ffebee; border-left: 3px solid #c62828; border-radius: 4px; color: #c62828; font-size: 11px; margin-bottom: 4px;">
❌ Còn <strong>{s_mandatory_failed}</strong> tiêu chí bắt buộc chưa đạt
</div>
<div style="padding: 6px 10px; background: #fff8e1; border-left: 3px solid #ffa000; border-radius: 4px; color: #e65100; font-size: 11px;">
⚠️ Tỉ lệ đạt chỉ {s_compliance}% (yêu cầu ≥80%)
</div>'''
elif s_mandatory_failed > 0:
note_html = f'''
<div style="padding: 6px 10px; background: #fff8e1; border-left: 3px solid #ffa000; border-radius: 4px; color: #e65100; font-size: 11px;">
⚠️ Còn <strong style="color: #c62828;">{s_mandatory_failed}</strong> tiêu chí bắt buộc chưa đạt
</div>'''
elif s_compliance < 80:
note_html = f'''
<div style="padding: 6px 10px; background: #fff8e1; border-left: 3px solid #ffa000; border-radius: 4px; color: #e65100; font-size: 11px;">
⚠️ Tỉ lệ đạt chỉ {s_compliance}% (yêu cầu ≥80%)
</div>'''
elif s_mandatory_total > 0:
note_html = '''
<div style="padding: 6px 10px; background: #e8f5e9; border-left: 3px solid #4caf50; border-radius: 4px; color: #2e7d32; font-size: 11px;">
✅ Đạt yêu cầu
</div>'''
else:
note_html = '<span style="color: #bbb; font-style: italic; font-size: 11px;">Không có dữ liệu</span>'
row_bg = "#fafbfc" if idx_s % 2 == 0 else "#ffffff"
summary_rows += f'''
<tr style="background: {row_bg};">
<td style="padding: 12px 14px; text-align: center; color: #666; font-weight: 500; border-bottom: 1px solid #f0f0f0;">{idx_s + 1}</td>
<td style="padding: 12px 14px; border-bottom: 1px solid #f0f0f0;">
<div style="font-weight: 600; color: #1565c0;">{si.get('hostname', 'N/A')}</div>
<div style="font-size: 11px; color: #999; margin-top: 2px;">{si.get('os_name', '')}</div>
</td>
<td style="padding: 12px 14px; color: #333; font-family: Consolas, monospace; font-size: 13px; border-bottom: 1px solid #f0f0f0;">{si.get('ip_address', 'N/A')}</td>
<td style="padding: 12px 14px; text-align: center; border-bottom: 1px solid #f0f0f0;">
<span style="display: inline-block; padding: 4px 12px; border-radius: 15px; font-weight: 700; font-size: 13px; background: {s_pct_bg}; color: {s_pct_color};">{s_compliance}%</span>
<div style="font-size: 10px; color: #999; margin-top: 3px;">{si.get('passed_count', 0)}/{si.get('total_checks', 0)} tiêu chí</div>
</td>
<td style="padding: 12px 14px; text-align: center; border-bottom: 1px solid #f0f0f0;">
<span style="display: inline-block; padding: 4px 12px; border-radius: 15px; font-weight: 700; font-size: 13px; background: {s_mand_bg}; color: {s_mand_color};">{s_mandatory_passed}/{s_mandatory_total}</span>
{f'<div style="font-size: 10px; color: #999; margin-top: 3px;">{s_mandatory_pct}%</div>' if s_mandatory_total > 0 else ''}
</td>
<td style="padding: 12px 14px; border-bottom: 1px solid #f0f0f0;">{note_html}</td>
</tr>'''
# Summary footer row (if multiple servers)
summary_footer = ""
if len(valid_infos) > 1:
total_passed_all = sum(s.get('passed_count', 0) for s in valid_infos)
total_checks_all = sum(s.get('total_checks', 0) for s in valid_infos)
avg_pct = round((total_passed_all / total_checks_all) * 100, 1) if total_checks_all > 0 else 0
total_mp = sum(s.get('mandatory_passed', 0) for s in valid_infos)
total_mt = sum(s.get('mandatory_total', 0) for s in valid_infos)
total_mf = sum(s.get('mandatory_failed', 0) for s in valid_infos)
if avg_pct >= 80:
avg_bg = "#e8f5e9"; avg_color = "#2e7d32"
elif avg_pct >= 50:
avg_bg = "#fff3e0"; avg_color = "#e65100"
else:
avg_bg = "#ffebee"; avg_color = "#c62828"
footer_note = f'<span style="color: #c62828; font-weight: 600;">⚠️ Tổng {total_mf} tiêu chí bắt buộc chưa đạt</span>' if total_mf > 0 else '<span style="color: #2e7d32; font-weight: 600;">✅ Tất cả đạt</span>'
summary_footer = f'''
<tr style="background: linear-gradient(135deg, #f5f5f5, #eeeeee); font-weight: 600;">
<td colspan="3" style="padding: 14px 16px; text-align: right; color: #555; font-size: 13px; border-top: 2px solid #e0e0e0;">
📈 Tổng hợp ({len(valid_infos)} máy chủ):
</td>
<td style="padding: 14px 16px; text-align: center; border-top: 2px solid #e0e0e0;">
<span style="display: inline-block; padding: 4px 12px; border-radius: 15px; font-weight: 700; font-size: 13px; background: {avg_bg}; color: {avg_color};">{avg_pct}%</span>
</td>
<td style="padding: 14px 16px; text-align: center; border-top: 2px solid #e0e0e0;">
<span style="font-size: 13px; color: #333;">{total_mp}/{total_mt}</span>
</td>
<td style="padding: 14px 16px; border-top: 2px solid #e0e0e0;">{footer_note}</td>
</tr>'''
summary_table_html = f'''
<h3 style="color: #1976d2; margin: 25px 0 15px 0; font-size: 18px;">📋 Bảng tổng hợp kết quả</h3>
<div style="overflow-x: auto; border-radius: 10px; border: 1px solid #e0e0e0;">
<table style="width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; border-radius: 10px; overflow: hidden;">
<thead>
<tr style="background: linear-gradient(135deg, #1565c0 0%, #1976d2 100%);">
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: center; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap;">STT</th>
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: left; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap;">🖥️ Tên máy chủ</th>
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: left; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap;">🌐 Địa chỉ IP</th>
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: center; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap;">📊 Tỉ lệ đạt</th>
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: center; font-size: 12px; letter-spacing: 0.5px; white-space: nowrap;">🔒 Bắt buộc đạt</th>
<th style="padding: 12px 14px; color: white; font-weight: 600; text-align: left; font-size: 12px; letter-spacing: 0.5px; min-width: 180px;">📝 Ghi chú</th>
</tr>
</thead>
<tbody>
{summary_rows}
</tbody>
{f"<tfoot>{summary_footer}</tfoot>" if summary_footer else ""}
</table>
</div>'''
# Tạo nội dung HTML với màu tối hơn cho chế độ sáng
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; color: #333333; margin: 0; padding: 0; background: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #1565c0; color: #ffffff; padding: 25px; border-radius: 12px 12px 0 0; text-align: center; }}
.content {{ background: #ffffff; padding: 25px; border: 1px solid #e0e0e0; color: #333333; }}
.footer {{ background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #555555; border-radius: 0 0 12px 12px; border: 1px solid #e0e0e0; border-top: none; }}
.success {{ color: #1b5e20; background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin-bottom: 20px; }}
.info-table {{ width: 100%; border-collapse: collapse; margin: 15px 0; }}
.info-table td {{ padding: 10px 12px; border-bottom: 1px solid #eee; color: #333333; }}
.info-table td:first-child {{ font-weight: bold; width: 40%; color: #444444; }}
ul {{ margin: 10px 0; padding-left: 20px; list-style: none; }}
a {{ color: #1565c0; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.note {{ background: #e7f3ff; border-left: 4px solid #2196F3; padding: 12px 15px; margin: 20px 0; border-radius: 5px; font-size: 13px; color: #333333; }}
h3 {{ color: #1565c0; }}
p {{ color: #333333; }}
</style>
</head>
<body style="color: #333333;">
<div class="container">
<div class="header" style="background-color: #1565c0;">
<h2 style="margin: 0; font-size: 24px; color: #ffffff !important;">🔒 Audit Hardening Tool</h2>
<p style="margin: 8px 0 0 0; font-size: 14px; color: #ffffff !important;">Thông báo xử lý hoàn tất</p>
</div>
<div class="content">
<div class="success">
<strong>✅ Xử lý file audit đã hoàn tất thành công!</strong>
</div>
<table class="info-table">
<tr><td>Hệ điều hành:</td><td>{os_type.upper()}</td></tr>
<tr><td>Số file đã tạo:</td><td>{len(output_files)} file(s)</td></tr>
<tr><td>Thời gian xử lý:</td><td>{processing_time:.2f} giây</td></tr>
<tr><td>Thời điểm:</td><td>{timestamp}</td></tr>
</table>
{summary_table_html}
{'<h3 style="color: #1976d2; margin: 25px 0 15px 0; font-size: 18px;">🖥️ Thông tin chi tiết hệ thống</h3>' + system_info_html if system_info_html else ''}
<h3 style="color: #1976d2; margin: 25px 0 15px 0; font-size: 18px;">📄 Danh sách file kết quả</h3>
<ul>
{files_html}
</ul>
<p style="margin-top: 25px; text-align: center;">
<a href="{base_url}/my_files" style="background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); color: white; padding: 12px 25px; border-radius: 8px; display: inline-block; font-weight: 600; text-decoration: none;">
📁 Xem tất cả file của tôi
</a>
</p>
</div>
<div class="footer">
<p style="margin: 0;">Email này được gửi tự động từ Audit Hardening Tool</p>
<p style="margin: 5px 0 0 0;">VNPT-MEDIA - An Toàn Thông Tin</p>
</div>
</div>
</body>
</html>
"""
# Tạo nội dung text thuần
body_text = f"""
Audit Hardening Tool - Thông báo xử lý hoàn tất
===============================================
✅ Xử lý file audit đã hoàn tất thành công!
Thông tin:
- Hệ điều hành: {os_type.upper()}
- Số file đã tạo: {len(output_files)} file(s)
- Thời gian xử lý: {processing_time:.2f} giây
- Thời điểm: {timestamp}
{system_info_text if system_info_text else ''}
Danh sách file kết quả:
{files_text}
Truy cập {base_url}/my_files để xem tất cả file của bạn.
---
Email này được gửi tự động từ Audit Hardening Tool
VNPT-MEDIA - An Toàn Thông Tin
"""
# Tạo email với file đính kèm nếu là admin
email_attachments = None
if is_admin and attachments:
email_attachments = attachments
print(Fore.CYAN + f"[EMAIL] Admin user - attaching {len(attachments)} file(s)" + Fore.RESET)
# ========== Chọn phương thức gửi email ==========
if self.email_method == 'api':
# Gửi qua HTTP API
return self._send_via_api(
to_emails=to_emails,
subject=subject,
html_content=body_html,
attachments=email_attachments
)
else:
# Gửi qua SMTP (phương thức cũ)
msg = self._create_email(
to_emails=to_emails,
subject=subject,
body_html=body_html,
body_text=body_text,
attachments=email_attachments
)
print(Fore.CYAN + f"[EMAIL] Connecting to SMTP server: {self.config.get('smtp_server')}:{self.config.get('smtp_port')}..." + Fore.RESET)
smtp = smtplib.SMTP(
self.config.get('smtp_server', 'email.vnpt.vn'),
self.config.get('smtp_port', 587),
timeout=self.config.get('timeout', 30)
)
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
smtp.login(
self.config.get('sender_email', ''),
self.config.get('sender_password', '')
)
smtp.sendmail(
self.config.get('sender_email', ''),
to_emails,
msg.as_string()
)
smtp.quit()
print(Fore.GREEN + f"[EMAIL] ✓ Notification sent to: {', '.join(to_emails)}" + Fore.RESET)
# Lưu vào thư mục Sent
self._save_to_sent_folder(msg)
return True
except smtplib.SMTPException as e:
print(Fore.RED + f"[EMAIL] ✗ SMTP Error: {e}" + Fore.RESET)
return False
except Exception as e:
print(Fore.RED + f"[EMAIL] ✗ Error sending email: {e}" + Fore.RESET)
return False
# Global instance
_email_notifier = None
def get_email_notifier() -> EmailNotifier:
"""Lấy instance EmailNotifier (singleton pattern)"""
global _email_notifier
if _email_notifier is None:
_email_notifier = EmailNotifier()
return _email_notifier
def send_processing_complete_email(
output_files: List[str],
os_type: str,
processing_time: float = 0,
recipients: List[str] = None,
base_url: str = "http://localhost:8000",
system_info_list: List[dict] = None,
attachments: List[str] = None,
is_admin: bool = False
) -> bool:
"""
Hàm tiện ích để gửi email thông báo xử lý hoàn tất
Args:
output_files: Danh sách tên file output
os_type: Loại OS đã xử lý
processing_time: Thời gian xử lý (giây)
recipients: Email nhận (None = dùng mặc định)
base_url: URL của ứng dụng
system_info_list: Danh sách thông tin hệ thống từ các file đã xử lý
attachments: Danh sách đường dẫn file đính kèm
is_admin: True nếu admin đăng nhập
Returns:
True nếu thành công
"""
notifier = get_email_notifier()
return notifier.send_processing_notification(
output_files=output_files,
os_type=os_type,
processing_time=processing_time,
recipients=recipients,
base_url=base_url,
system_info_list=system_info_list,
attachments=attachments,
is_admin=is_admin
)
if __name__ == "__main__":
# Test gửi email
print("=" * 50)
print(" Test Email Notification")
print("=" * 50)
success = send_processing_complete_email(
output_files=["Test_Result_2025-02-03.xlsx"],
os_type="centos",
processing_time=5.23,
base_url="http://localhost:8000"
)
if success:
print("\n✅ Email sent successfully!")
else:
print("\n❌ Failed to send email")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+59
View File
@@ -0,0 +1,59 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime
from datetime import datetime
from database import Base
class AuditRecord(Base):
__tablename__ = "audit_records"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String, index=True, unique=True)
file_size = Column(Integer)
file_date = Column(String) # Stored as formatted string for simplicity: '%d/%m/%Y %H:%M'
hostname = Column(String, index=True)
ip_address = Column(String, index=True)
os_name = Column(String, index=True)
compliance_percentage = Column(Float, default=0.0)
passed_count = Column(Integer, default=0)
failed_count = Column(Integer, default=0)
total_checks = Column(Integer, default=0)
mandatory_passed = Column(Integer, default=0)
mandatory_failed = Column(Integer, default=0)
mandatory_total = Column(Integer, default=0)
mandatory_percentage = Column(Float, default=0.0)
optional_passed = Column(Integer, default=0)
optional_failed = Column(Integer, default=0)
optional_total = Column(Integer, default=0)
has_txt = Column(Boolean, default=False)
txt_filename = Column(String, default="")
created_at = Column(DateTime, default=datetime.utcnow)
class UserEmailRecord(Base):
"""Lưu trữ email người dùng (@vnpt.vn) và thống kê số file đã upload."""
__tablename__ = "user_email_records"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, index=True, unique=True) # email @vnpt.vn (unique per user)
display_name = Column(String, default="") # phần trước @, dùng để hiển thị
total_uploads = Column(Integer, default=0) # tổng số lần upload (batch)
total_files = Column(Integer, default=0) # tổng số file đã xử lý
first_seen = Column(DateTime, default=datetime.utcnow)
last_seen = Column(DateTime, default=datetime.utcnow)
class UserFileRecord(Base):
"""Lưu vết các file thuộc quyền sở hữu của user (đã đăng nhập)."""
__tablename__ = "user_file_records"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String, index=True)
username = Column(String, index=True)
unit_id = Column(String, index=True)
unit_name = Column(String, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
+651
View File
@@ -0,0 +1,651 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<script>
var savedTheme = localStorage.getItem('audit_theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="{{ url_for('icon', path='/icon.png') }}">
<title>Quản lý Công cụ - Admin - VNPT-MEDIA</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-main);
min-height: 100vh;
padding: 20px;
color: var(--text-main);
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: var(--card-bg);
border-radius: 15px;
backdrop-filter: blur(10px);
}
.header h1 {
color: var(--text-main);
font-size: 24px;
}
.header-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
border: none;
font-size: 14px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.card {
background: var(--card-bg);
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 18px;
color: var(--text-main);
}
.tools-table {
width: 100%;
border-collapse: collapse;
}
.tools-table th,
.tools-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.tools-table th {
color: var(--text-muted);
font-weight: 500;
font-size: 13px;
text-transform: uppercase;
}
.tools-table tr:hover {
background: var(--card-bg);
}
.status-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.status-available {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
}
.status-unavailable {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
}
.os-icon {
font-size: 20px;
margin-right: 8px;
}
/* Upload Form */
.upload-form {
background: var(--table-row-hover);
border: 2px dashed var(--border-color);
border-radius: 15px;
padding: 30px;
text-align: center;
transition: all 0.3s ease;
}
.upload-form:hover {
border-color: #667eea;
background: var(--empty-state-bg);
}
.upload-form.dragover {
border-color: #667eea;
background: var(--hover-bg);
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-muted);
font-size: 13px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 15px;
border-radius: 8px;
border: 1px solid var(--input-border);
background: var(--card-bg);
color: var(--text-main);
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-group select option {
background: var(--input-bg);
color: var(--input-text);
padding: 10px;
}
.form-group input[type="file"] {
padding: 10px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.alert {
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.alert-success {
background: rgba(46, 204, 113, 0.2);
color: #2ecc71;
border: 1px solid rgba(46, 204, 113, 0.3);
}
.alert-error {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.3);
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 15px;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
gap: 15px;
}
.tools-table {
font-size: 13px;
}
.tools-table th,
.tools-table td {
padding: 10px;
}
}
</style>
</head>
<body>
{% include 'header.html' %}
<div class="container">
<!-- Top Navigation Tabs -->
<div class="top-nav-tabs"
style="display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; background: rgba(25, 118, 210, 0.05); padding: 15px; border-radius: 12px; border: 1px solid rgba(25, 118, 210, 0.1);">
<a href="{{ url_for('index') }}" class="nav-tab"
style="padding: 10px 20px; background: var(--card-bg); color: var(--text-main); text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; border: 1px solid var(--border-color); box-shadow: 0 2px 5px rgba(0,0,0,0.05);">🏠 Trang chủ</a>
<a href="{{ url_for('my_files') }}" class="nav-tab"
style="padding: 10px 20px; background: var(--card-bg); color: var(--text-main); text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; border: 1px solid var(--border-color); box-shadow: 0 2px 5px rgba(0,0,0,0.05);">📁 File của tôi</a>
{% if user_info and user_info.role == 'admin' %}
<a href="{{ url_for('admin_tools') }}" class="nav-tab"
style="padding: 10px 20px; background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(25, 118, 210, 0.3);">⚙️ Quản trị</a>
{% endif %}
</div>
{% if user_info and user_info.role == 'admin' %}
<!-- Admin Sub Navigation Tabs (Dark mode style) -->
<div class="admin-sub-tabs"
style="display: flex; justify-content: center; gap: 8px; margin-bottom: 25px; flex-wrap: wrap;">
<a href="{{ url_for('admin_tools') }}" class="sub-tab"
style="padding: 8px 16px; background: #667eea; color: white; text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; box-shadow: 0 2px 5px rgba(102, 126, 234, 0.3);">🔧 Cài đặt chung</a>
<a href="{{ url_for('admin_users_page') }}" class="sub-tab"
style="padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: var(--text-main); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid var(--input-border);">👥 Người dùng & Đơn vị</a>
<a href="{{ url_for('list_outputs') }}" class="sub-tab"
style="padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: var(--text-main); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid var(--input-border);">📂 Tất cả File</a>
<a href="{{ url_for('server_stats_page') }}" class="sub-tab"
style="padding: 8px 16px; background: rgba(255, 255, 255, 0.1); color: var(--text-main); text-decoration: none; border-radius: 20px; font-weight: 600; font-size: 13px; transition: all 0.3s ease; border: 1px solid var(--input-border);">📊 Thống kê</a>
</div>
{% endif %}
<div class="header">
<h1>🔧 Quản lý Công cụ Đánh giá</h1>
<div class="header-actions">
<a href="{{ url_for('index') }}" class="btn btn-secondary">← Về trang chủ</a>
</div>
</div>
{% if message %}
<div class="alert alert-{{ message.type }}">
{{ message.text }}
</div>
{% endif %}
<!-- Upload New Tool -->
<div class="card">
<div class="card-header">
<h2 class="card-title">📤 Tải lên Công cụ mới</h2>
</div>
<form action="tools/upload" method="post" enctype="multipart/form-data" class="upload-form" id="uploadForm">
<div class="form-row">
<div class="form-group">
<label>Hệ điều hành *</label>
<select name="os_type" required>
<option value="">-- Chọn OS --</option>
{% for os_key, meta in os_metadata.items() %}
<option value="{{ os_key }}">{{ meta.icon }} {{ meta.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Phiên bản OS (vd: 22.04, 24.04, 2019) *</label>
<input type="text" name="os_version_label" placeholder="22.04" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Tên công cụ *</label>
<input type="text" name="tool_name" placeholder="Ubuntu 22.04 Hardening" required>
</div>
<div class="form-group">
<label>Phiên bản script (vd: v2.0.0) *</label>
<input type="text" name="version" placeholder="v2.0.0" required>
</div>
</div>
<div class="form-row">
<div class="form-group" style="grid-column: 1 / -1;">
<label>File công cụ (.sh, .py, .ps1, .zip) *</label>
<input type="file" name="tool_file" accept=".sh,.py,.ps1,.zip,.tar.gz" required>
</div>
</div>
<button type="submit" class="btn btn-primary">⬆️ Tải lên</button>
</form>
</div>
<!-- Current Tools -->
<div class="card">
<div class="card-header">
<h2 class="card-title">📦 Công cụ hiện có</h2>
</div>
{% if tools_list %}
<table class="tools-table">
<thead>
<tr>
<th>Hệ điều hành</th>
<th>Phiên bản OS</th>
<th>Tên công cụ</th>
<th>Script ver.</th>
<th>Cập nhật</th>
<th>Trạng thái</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
{% for tool in tools_list %}
<tr>
<td><span class="os-icon">{{ tool.icon }}</span>{{ tool.display_name }}</td>
<td>{{ tool.os_version_label or '-' }}</td>
<td>{{ tool.name }}</td>
<td><code>{{ tool.version }}</code></td>
<td>{{ tool.updated }}</td>
<td>
{% if tool.available %}
<span class="status-badge status-available">✓ Có sẵn</span>
{% else %}
<span class="status-badge status-unavailable">✗ Chưa có</span>
{% endif %}
</td>
<td style="white-space: nowrap;">
{% if tool.available %}
<div style="display: inline-flex; gap: 6px; align-items: center;">
<a href="{{ url_for('download_tool', version_key=tool.version_key) }}"
class="btn btn-secondary" style="padding: 5px 10px; font-size: 12px; white-space: nowrap;">⬇️ Tải</a>
<form action="tools/delete/{{ tool.version_key }}" method="post" style="display: inline; margin: 0;"
onsubmit="return confirm('Bạn có chắc muốn xóa công cụ này?')">
<button type="submit" class="btn btn-danger"
style="padding: 5px 10px; font-size: 12px; white-space: nowrap;">🗑️ Xóa</button>
</form>
</div>
{% else %}
<span style="color: var(--text-muted);">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>Chưa có công cụ nào được tải lên.</p>
</div>
{% endif %}
</div>
<!-- Upload Documentation Form -->
<div class="card">
<div class="card-header">
<h2 class="card-title">⬆️ Tải lên Tài liệu Cấu hình mới</h2>
</div>
<form action="{{ url_for('admin_docs_upload') }}" method="post" enctype="multipart/form-data">
<div class="form-row">
<div class="form-group">
<label>Tên tài liệu *</label>
<input type="text" name="doc_name" placeholder="VD: Hướng dẫn Hardening OS" required>
</div>
<div class="form-group">
<label>Phiên bản tài liệu (vd: v1.0) *</label>
<input type="text" name="doc_version" placeholder="v1.0" value="v1.0" required>
</div>
</div>
<div class="form-row">
<div class="form-group" style="grid-column: 1 / -1;">
<label>File tài liệu (.pdf, .docx, .xlsx, .zip) *</label>
<input type="file" name="doc_file" required>
</div>
</div>
<button type="submit" class="btn btn-primary">⬆️ Tải lên Tài liệu</button>
</form>
</div>
<!-- Current Documentation -->
<div class="card">
<div class="card-header">
<h2 class="card-title">📚 Quản lý Tài liệu Cấu hình hiện có</h2>
</div>
{% if docs_list %}
<table class="tools-table">
<thead>
<tr>
<th>Tên tài liệu</th>
<th>Tên file</th>
<th>Phiên bản</th>
<th>Kích thước</th>
<th>Cập nhật</th>
<th>Trạng thái</th>
<th>Thao tác</th>
</tr>
</thead>
<tbody>
{% for doc in docs_list %}
<tr>
<td><strong>📄 {{ doc.name }}</strong></td>
<td><code>{{ doc.file }}</code></td>
<td><code>{{ doc.version }}</code></td>
<td>{{ doc.size }}</td>
<td>{{ doc.updated }}</td>
<td>
{% if doc.available %}
<span class="status-badge status-available">✓ Có sẵn</span>
{% else %}
<span class="status-badge status-unavailable">✗ Lỗi file</span>
{% endif %}
</td>
<td style="white-space: nowrap;">
{% if doc.available %}
<div style="display: inline-flex; gap: 6px; align-items: center;">
<a href="{{ url_for('download_doc', doc_key=doc.doc_key) }}"
class="btn btn-secondary" style="padding: 5px 10px; font-size: 12px; white-space: nowrap;">⬇️ Tải</a>
<form action="{{ url_for('admin_docs_delete', doc_key=doc.doc_key) }}" method="post" style="display: inline; margin: 0;"
onsubmit="return confirm('Bạn có chắc muốn xóa tài liệu này?')">
<button type="submit" class="btn btn-danger"
style="padding: 5px 10px; font-size: 12px; white-space: nowrap;">🗑️ Xóa</button>
</form>
</div>
{% else %}
<form action="{{ url_for('admin_docs_delete', doc_key=doc.doc_key) }}" method="post" style="display: inline; margin: 0;"
onsubmit="return confirm('Bạn có chắc muốn xóa bản ghi này?')">
<button type="submit" class="btn btn-danger"
style="padding: 5px 10px; font-size: 12px; white-space: nowrap;">🗑️ Xóa</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>Chưa có tài liệu cấu hình nào được tải lên.</p>
</div>
{% endif %}
</div>
<!-- Email Notification Settings -->
<div class="card">
<div class="card-header">
<h2 class="card-title">⚙️ Cài đặt Hệ thống</h2>
</div>
<div style="display: flex; align-items: flex-start; gap: 30px; flex-wrap: wrap;">
<!-- Email Toggle Block -->
<div style="flex: 1; min-width: 280px;">
<p style="color: var(--text-muted); font-size: 13px; margin-bottom: 15px;">
Bật / Tắt chức năng gửi email thông báo khi xử lý file hoàn tất.
</p>
{% if not email_module_available %}
<!-- Module không có sẵn -->
<div style="display: flex; align-items: center; gap: 15px; padding: 18px 20px; background: rgba(231,76,60,0.12); border: 1px solid rgba(231,76,60,0.3); border-radius: 12px;">
<span style="font-size: 32px;">📧</span>
<div>
<div style="font-weight: 600; font-size: 15px; color: #e74c3c;">Không khả dụng</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
Module <code style="background: rgba(255,255,255,0.1); padding: 1px 6px; border-radius: 4px;">email_notifier</code> chưa được cài đặt trên server này.
</div>
</div>
</div>
{% elif email_enabled %}
<!-- Module có, đang BẬT -->
<div style="display: flex; align-items: center; gap: 15px; padding: 18px 20px; background: rgba(46,204,113,0.12); border: 1px solid rgba(46,204,113,0.3); border-radius: 12px; margin-bottom: 15px;">
<span style="font-size: 32px;"></span>
<div>
<div style="font-weight: 600; font-size: 15px; color: #2ecc71;">Đang BẬT</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
Hệ thống sẽ gửi email thông báo sau mỗi lần xử lý file thành công.
</div>
</div>
</div>
<form action="email/disable" method="post">
<button type="submit" class="btn btn-danger" style="width: 100%;">
🔕 Tắt gửi Email
</button>
</form>
{% else %}
<!-- Module có, đang TẮT -->
<div style="display: flex; align-items: center; gap: 15px; padding: 18px 20px; background: var(--card-bg); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; margin-bottom: 15px;">
<span style="font-size: 32px;">🔕</span>
<div>
<div style="font-weight: 600; font-size: 15px; color: var(--text-muted);">Đang TẮT</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">
Không gửi email sau khi xử lý. Nhấn bên dưới để bật.
</div>
</div>
</div>
<form action="email/enable" method="post">
<button type="submit" class="btn btn-primary" style="width: 100%;">
📧 Bật gửi Email
</button>
</form>
{% endif %}
<p style="margin-top: 12px; font-size: 11px; color: #666; font-style: italic;">
⚠️ Lưu ý: cài đặt này chỉ giữ trong phiên chạy hiện tại. Khi khởi động lại server, email sẽ trở về trạng thái <strong>TẮT</strong>.
</p>
</div>
</div>
</div>
<!-- User Email Stats -->
<div class="card">
<div class="card-header">
<h2 class="card-title">👥 Thống kê Người dùng (VNPT Email)</h2>
</div>
{% if email_stats and email_stats|length > 0 %}
<div style="overflow-x: auto;">
<table class="tools-table">
<thead>
<tr>
<th>Người dùng</th>
<th>Email</th>
<th>Số lần Upload</th>
<th>Tổng File</th>
<th>Lần đầu</th>
<th>Gần nhất</th>
</tr>
</thead>
<tbody>
{% for stat in email_stats %}
<tr>
<td style="font-weight: 600; color: var(--text-main);">{{ stat.display_name }}</td>
<td style="color: #90caf9;">{{ stat.email }}</td>
<td><span class="status-badge" style="background: rgba(102,126,234,0.2); color: #8ba4f9;">{{ stat.total_uploads }}</span></td>
<td><span class="status-badge status-available">{{ stat.total_files }}</span></td>
<td style="font-size: 13px; color: var(--text-muted);">{{ stat.first_seen }}</td>
<td style="font-size: 13px; color: var(--text-muted);">{{ stat.last_seen }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>Chưa có dữ liệu thống kê từ người dùng.</p>
</div>
{% endif %}
</div>
</div>
<script>
// Drag and drop support
const uploadForm = document.getElementById('uploadForm');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadForm.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadForm.addEventListener(eventName, () => uploadForm.classList.add('dragover'), false);
});
['dragleave', 'drop'].forEach(eventName => {
uploadForm.addEventListener(eventName, () => uploadForm.classList.remove('dragover'), false);
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
<script>
// Theme initialization (Anti-FOUC)
(function() {
var savedTheme = localStorage.getItem('audit_theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<style>
:root {
--bg-main: #f4f6f9;
--card-bg: #ffffff;
--text-main: #2b3445;
--text-muted: #7d879c;
--border-color: #e3e9ef;
--border-color-light: #f1f5f9;
--primary-color: #1976d2;
--hover-bg: #f8f9fa;
--input-bg: #ffffff;
--input-text: #2b3445;
--input-border: #ced4da;
--sub-tab-bg: #e3f2fd;
--sub-tab-text: #1565c0;
--sub-tab-border: #bbdefb;
--sub-tab-active-bg: #1565c0;
--sub-tab-active-text: #ffffff;
--empty-state-bg: #f8f9fa;
--table-header-bg: #f1f5f9;
--table-header-text: #475569;
--table-row-hover: #f8fafc;
--table-border: #e2e8f0;
--modal-bg: #ffffff;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
--success-color: #2e7d32;
--success-bg: #e8f5e9;
--danger-color: #c62828;
--danger-bg: #ffebee;
--warning-color: #e65100;
--warning-bg: #fff3e0;
--info-color: #0277bd;
--info-bg: #e1f5fe;
}
[data-theme="dark"] {
--bg-main: #0f172a;
--card-bg: #1e293b;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border-color: #334155;
--border-color-light: #1e293b;
--primary-color: #3b82f6;
--hover-bg: #334155;
--input-bg: #0f172a;
--input-text: #f1f5f9;
--input-border: #475569;
--sub-tab-bg: #1e293b;
--sub-tab-text: #e2e8f0;
--sub-tab-border: #334155;
--sub-tab-active-bg: #3b82f6;
--sub-tab-active-text: #ffffff;
--empty-state-bg: #1e293b;
--table-header-bg: #334155;
--table-header-text: #e2e8f0;
--table-row-hover: #1e293b;
--table-border: #334155;
--modal-bg: #1e293b;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--success-color: #4caf50;
--success-bg: rgba(76, 175, 80, 0.1);
--danger-color: #ef5350;
--danger-bg: rgba(244, 67, 54, 0.1);
--warning-color: #ff9800;
--warning-bg: rgba(255, 152, 0, 0.1);
--info-color: #29b6f6;
--info-bg: rgba(3, 169, 244, 0.1);
}
.global-header {
width: 100%;
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%);
color: white;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
/* Đẩy toàn bộ nội dung body xuống để không bị header che mất */
body {
margin-top: 60px !important;
background-color: var(--bg-main);
color: var(--text-main);
transition: background-color 0.3s ease, color 0.3s ease;
}
.header-logo {
font-weight: 700;
font-size: 18px;
display: flex;
align-items: center;
gap: 10px;
}
.header-logo span {
background: rgba(255, 255, 255, 0.2);
padding: 3px 8px;
border-radius: 5px;
font-size: 14px;
letter-spacing: 1px;
}
.header-user-info {
display: flex;
align-items: center;
gap: 15px;
font-size: 14px;
}
.header-user-details {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.header-user-name {
font-weight: 600;
}
.header-user-role {
font-size: 12px;
color: #bbdefb;
}
.header-action-btn {
background: rgba(255, 255, 255, 0.1);
color: white;
text-decoration: none;
padding: 6px 15px;
border-radius: 5px;
font-weight: 600;
transition: background 0.3s;
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.header-action-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.theme-toggle-btn {
padding: 6px 10px;
font-size: 16px;
}
.header-guest {
background: rgba(255, 255, 255, 0.1);
padding: 6px 15px;
border-radius: 5px;
font-size: 14px;
}
</style>
<div class="global-header">
<div class="header-logo">
🛡️ Audit Hardening Tool <span>VNPT-MEDIA</span>
</div>
<div class="header-user-info">
<button id="themeToggleBtn" class="header-action-btn theme-toggle-btn" title="Chuyển đổi giao diện Sáng/Tối">
<span id="themeIcon">🌞</span>
</button>
{% if is_logged_in and user_info %}
<div class="header-user-details">
<span class="header-user-name">👤 {{ user_info.full_name or user_info.username }}</span>
{% if user_info.role %}
<span class="header-user-role">{{ user_info.role|upper }} {% if user_info.unit_name %} | {{ user_info.unit_name }}{% endif %}</span>
{% endif %}
</div>
<a href="{{ url_for('logout') }}" class="header-action-btn">🚪 Đăng xuất</a>
{% else %}
<div class="header-guest">
👤 Khách
</div>
{% endif %}
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const toggleBtn = document.getElementById('themeToggleBtn');
const themeIcon = document.getElementById('themeIcon');
function updateIcon() {
if (document.documentElement.getAttribute('data-theme') === 'dark') {
themeIcon.textContent = '🌙';
} else {
themeIcon.textContent = '🌞';
}
}
updateIcon();
toggleBtn.addEventListener('click', function() {
let currentTheme = document.documentElement.getAttribute('data-theme');
let newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('audit_theme', newTheme);
updateIcon();
});
});
</script>
File diff suppressed because it is too large Load Diff
+599
View File
@@ -0,0 +1,599 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<script>
var savedTheme = localStorage.getItem('audit_theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="{{ url_for('icon', path='/icon.png') }}">
<title>Đăng nhập - Công cụ Kiểm tra Hardening - VNPT-MEDIA</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-main: #f4f6f9;
--card-bg: #ffffff;
--text-main: #2b3445;
--text-muted: #7d879c;
--border-color: #e3e9ef;
--border-color-light: #f1f5f9;
--primary-color: #1976d2;
--hover-bg: #f8f9fa;
--input-bg: #ffffff;
--input-text: #2b3445;
--input-border: #ced4da;
--empty-state-bg: #f8f9fa;
--success-color: #2e7d32;
--success-bg: #e8f5e9;
--danger-color: #c62828;
--danger-bg: #ffebee;
--warning-color: #e65100;
--warning-bg: #fff3e0;
--info-color: #0277bd;
--info-bg: #e1f5fe;
}
[data-theme="dark"] {
--bg-main: #0f172a;
--card-bg: #1e293b;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border-color: #334155;
--border-color-light: #1e293b;
--primary-color: #3b82f6;
--hover-bg: #334155;
--input-bg: #0f172a;
--input-text: #f1f5f9;
--input-border: #475569;
--empty-state-bg: #1e293b;
--success-color: #4caf50;
--success-bg: rgba(76, 175, 80, 0.1);
--danger-color: #ef5350;
--danger-bg: rgba(244, 67, 54, 0.1);
--warning-color: #ff9800;
--warning-bg: rgba(255, 152, 0, 0.1);
--info-color: #29b6f6;
--info-bg: rgba(3, 169, 244, 0.1);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-main);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 20px 10px;
position: relative;
overflow-x: hidden;
overflow-y: auto;
}
body::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background:
linear-gradient(135deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.05) 48%, rgba(33, 150, 243, 0.05) 52%, transparent 52%, transparent 100%),
linear-gradient(45deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.08) 48%, rgba(33, 150, 243, 0.08) 52%, transparent 52%, transparent 100%),
linear-gradient(135deg, transparent 0%, transparent 48%, rgba(33, 150, 243, 0.03) 48%, rgba(33, 150, 243, 0.03) 52%, transparent 52%, transparent 100%);
background-size: 300px 300px, 400px 400px, 500px 500px;
background-position: 0 0, 100px 100px, 200px 0;
z-index: 0;
pointer-events: none;
}
body::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 400px 400px 0;
border-color: transparent rgba(33, 150, 243, 0.03) transparent transparent;
z-index: 0;
pointer-events: none;
}
.menu-icon {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
display: flex;
flex-direction: column;
justify-content: space-around;
cursor: pointer;
padding: 8px;
}
.menu-icon span {
width: 100%;
height: 3px;
background: #1976d2;
border-radius: 2px;
}
.login-container {
background: var(--card-bg);
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
padding: 50px 55px;
width: 100%;
max-width: 480px;
position: relative;
z-index: 1;
}
.logo {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0;
margin-bottom: 35px;
padding-bottom: 20px;
border-bottom: 2px solid var(--sub-tab-border);
}
.logo img {
width: 65px;
height: 65px;
object-fit: contain;
flex-shrink: 0;
}
.logo-text {
flex: 1;
font-size: 30px;
font-weight: 800;
color: var(--primary-color);
letter-spacing: 0.35em;
text-align: center;
white-space: nowrap;
padding-right: 0.35em; /* compensate last letter-spacing */
}
.logo-icon {
display: inline-block;
width: 60px;
height: 60px;
background: #1976d2;
border-radius: 50% 0 50% 50%;
position: relative;
margin-right: 10px;
vertical-align: middle;
}
.logo-icon::after {
content: '';
position: absolute;
width: 30px;
height: 30px;
background: var(--card-bg);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.form-group {
margin-bottom: 20px;
}
.form-group input {
width: 100%;
padding: 14px 18px;
background-color: var(--input-bg);
color: var(--text-main);
border: 1px solid var(--input-border);
border-radius: 6px;
font-size: 15px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.password-wrapper {
position: relative;
}
.password-wrapper input {
padding-right: 45px;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
}
.btn-login {
width: 100%;
padding: 14px;
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
white-space: nowrap;
}
.btn-login:hover {
background: #1565c0;
}
/* SSO Portal Button */
.sso-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--text-muted);
font-size: 13px;
}
.sso-divider::before,
.sso-divider::after {
content: '';
flex: 1;
height: 1px;
background-color: var(--border-color);
}
.btn-sso {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #0d47a1, #1976d2);
color: white;
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
letter-spacing: 0.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
text-decoration: none;
}
.btn-sso:hover {
background: linear-gradient(135deg, #1565c0, #1e88e5);
box-shadow: 0 4px 15px rgba(25, 118, 210, 0.4);
transform: translateY(-1px);
}
.sso-info {
margin-top: 10px;
padding: 10px 14px;
background-color: var(--info-bg);
border-radius: 6px;
border-left: 3px solid #1976d2;
font-size: 12px;
color: var(--primary-color);
line-height: 1.6;
}
.help-text {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 12px;
color: var(--text-muted);
line-height: 1.8;
}
.help-text a {
color: var(--primary-color);
text-decoration: none;
}
.help-text a:hover {
text-decoration: underline;
}
.help-text strong {
color: var(--danger-color);
}
.footer {
position: absolute;
bottom: 20px;
text-align: center;
color: var(--primary-color);
font-size: 13px;
z-index: 1;
}
.footer strong {
color: var(--danger-color);
}
.alert {
padding: 12px;
border-radius: 5px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-error {
background-color: var(--danger-bg);
color: var(--danger-color);
border: 1px solid #ef9a9a;
}
.alert-success {
background-color: var(--success-bg);
color: var(--success-color);
border: 1px solid #a5d6a7;
}
/* Responsive Styles */
@media screen and (max-width: 768px) {
.login-container {
padding: 30px 25px;
}
.logo-text {
font-size: 38px;
letter-spacing: 5px;
}
.logo-icon {
width: 50px;
height: 50px;
}
.logo-icon::after {
width: 25px;
height: 25px;
}
.footer {
position: relative;
bottom: auto;
margin-top: 20px;
}
}
@media screen and (max-width: 480px) {
body {
padding: 10px 5px;
}
.login-container {
padding: 25px 20px;
border-radius: 12px;
}
.logo {
margin-bottom: 25px;
}
.logo-text {
font-size: 32px;
letter-spacing: 4px;
}
.logo-icon {
width: 40px;
height: 40px;
}
.logo-icon::after {
width: 20px;
height: 20px;
}
.form-group input {
padding: 10px 12px;
font-size: 13px;
}
.btn-login {
padding: 10px;
font-size: 14px;
}
.btn-guest {
padding: 10px;
font-size: 13px;
}
.help-text {
font-size: 11px;
margin-top: 20px;
padding-top: 15px;
}
.footer {
font-size: 11px;
position: relative;
bottom: auto;
margin-top: 15px;
}
.menu-icon {
top: 10px;
right: 10px;
width: 35px;
height: 35px;
}
}
</style>
</head>
<body>
<div class="menu-icon">
<span></span>
<span></span>
<span></span>
</div>
<div class="login-container">
<div class="logo">
<img src="{{ url_for('icon', path='/icon.png') }}" alt="VNPT-MEDIA Logo">
<span class="logo-text">VNPT-MEDIA</span>
</div>
{% if message %}
<div class="alert alert-{{ message.type }}">
{{ message.text }}
</div>
{% endif %}
<form method="post" action="login" id="loginForm">
<div class="form-group">
<div style="position: relative;">
<input
type="email"
id="emailInput"
name="username"
placeholder="ten.nv@vnpt.vn"
required
autocomplete="email"
style="padding-left: 44px;"
oninput="validateEmailDomain(this)"
>
<span style="position:absolute;left:14px;top:50%;transform:translateY(-50%);font-size:17px;pointer-events:none;">✉️</span>
</div>
<div id="emailHint" style="margin-top:6px;font-size:12px;color: var(--danger-color);display:none;">
⚠️ Vui lòng nhập email có đuôi <strong>@vnpt.vn</strong>
</div>
</div>
<div class="form-group">
<div class="password-wrapper">
<input type="password" id="password" name="password" placeholder="Mật khẩu" required autocomplete="current-password">
<span class="password-toggle" onclick="togglePassword()">👁</span>
</div>
</div>
<div class="form-group">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: nowrap;">
<span style="font-size: 14px; color: var(--text-muted); white-space: nowrap;">🔐 Mã xác thực 2FA (OTP)</span>
<span style="
background: linear-gradient(135deg, #1565c0, #1976d2);
color: white;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
">RADIUS</span>
</div>
<input type="text" name="otp" id="otp"
maxlength="6" pattern="[0-9]*" inputmode="numeric"
autocomplete="one-time-code"
style="letter-spacing: 8px; text-align: center; font-size: 18px; font-weight: 600;">
</div>
<button type="submit" class="btn-login">🔑 Đăng nhập qua RADIUS</button>
</form>
{% if sso_enabled %}
<div class="sso-divider">hoặc</div>
<div style="text-align:center; margin-bottom: 10px;">
<div class="sso-info">
🌐 <strong>Đăng nhập qua SSO Portal:</strong><br>
Truy cập hệ thống bằng tài khoản tập đoàn VNPT-MEDIA.<br>
SSO Portal sẽ tự động chuyển hướng bạn về đây sau khi xác thực.
</div>
</div>
{% endif %}
</div>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const toggle = document.querySelector('.password-toggle');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggle.textContent = '🙈';
} else {
passwordInput.type = 'password';
toggle.textContent = '👁';
}
}
// Validate domain @vnpt.vn khi người dùng gõ
function validateEmailDomain(input) {
const hint = document.getElementById('emailHint');
const val = input.value.trim().toLowerCase();
// Chỉ hiện cảnh báo khi người dùng đã gõ @ nhưng domain sai
if (val.includes('@') && !val.endsWith('@vnpt.vn')) {
hint.style.display = 'block';
input.style.borderColor = '#e53935';
} else {
hint.style.display = 'none';
input.style.borderColor = '';
}
}
// Chặn submit nếu domain sai
document.getElementById('loginForm').addEventListener('submit', function(e) {
const emailVal = document.getElementById('emailInput').value.trim().toLowerCase();
if (!emailVal.endsWith('@vnpt.vn')) {
e.preventDefault();
document.getElementById('emailHint').style.display = 'block';
document.getElementById('emailInput').style.borderColor = '#e53935';
document.getElementById('emailInput').focus();
}
});
// Auto-focus OTP field after password
document.getElementById('password').addEventListener('keydown', function(e) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (e.key === 'Enter') e.preventDefault();
document.getElementById('otp').focus();
}
});
// OTP: only allow digits
document.getElementById('otp').addEventListener('input', function(e) {
this.value = this.value.replace(/[^0-9]/g, '');
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff