v2.3
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user