From bdce6257674c079fec95b6a9c6c231eb62eb0ec5 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Sat, 15 Aug 2026 10:04:43 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20implementar=20pesta=C3=B1a=20de=20Gesti?= =?UTF-8?q?on=20de=20Cuentas=20y=20autenticacion=20global=20RADIUS=20con?= =?UTF-8?q?=20JIT=20provisioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/auth.py | 60 ++++- backend/app/api/users.py | 130 +++++++++ backend/app/core/radius.py | 112 ++++++++ backend/app/main.py | 2 + backend/app/models/models.py | 1 + backend/app/schemas/schemas.py | 16 ++ backend/app/services/settings_service.py | 4 + backend/migrate_users_auth.py | 23 ++ frontend/src/App.tsx | 7 + frontend/src/components/Sidebar.tsx | 4 +- frontend/src/pages/SettingsView.tsx | 66 ++++- frontend/src/pages/UsersView.tsx | 330 +++++++++++++++++++++++ frontend/src/services/api.ts | 42 +++ tests/test_auth_radius.py | 140 ++++++++++ windows-agent/agent/service.py | 23 +- 15 files changed, 941 insertions(+), 19 deletions(-) create mode 100644 backend/app/api/users.py create mode 100644 backend/app/core/radius.py create mode 100644 backend/migrate_users_auth.py create mode 100644 frontend/src/pages/UsersView.tsx create mode 100644 tests/test_auth_radius.py diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3e655d9..c361a69 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,27 +13,62 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) import logging logger = logging.getLogger("uvicorn.error") +from app.core.radius import authenticate_radius +from app.services.settings_service import get_setting + @router.post("/login", response_model=TokenResponse) async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)): email_clean = credentials.email.strip().lower() - result = await db.execute(select(User).where(User.email == email_clean)) - user = result.scalar_one_or_none() + auth_mode = await get_setting(db, "auth_mode") + radius_host = await get_setting(db, "radius_host") + radius_port = await get_setting(db, "radius_port") + radius_secret = await get_setting(db, "radius_secret") - if not user: - logger.warning(f"Login failed: User not found with email '{email_clean}'") + is_authenticated = False + user = None + + # 1. RADIUS Authentication Path (except for default admin fallback) + if auth_mode == "radius" and email_clean != "admin@oneverdrive.local": + username_radius = email_clean.split("@")[0] if "@" in email_clean else email_clean + try: + port_num = int(radius_port) if radius_port else 1812 + except ValueError: + port_num = 1812 + + radius_ok = authenticate_radius(username_radius, credentials.password, radius_host, radius_secret, port=port_num) + if radius_ok: + result = await db.execute(select(User).where(User.email == email_clean)) + user = result.scalar_one_or_none() + if not user: + # Auto-provision (JIT) RADIUS User + user = User( + email=email_clean, + hashed_password=get_password_hash(credentials.password), + full_name=username_radius.capitalize(), + role="OPERATOR", + auth_source="radius", + is_active=True + ) + db.add(user) + await db.commit() + await db.refresh(user) + is_authenticated = True + else: + # 2. Local Authentication Path (or Admin local login fallback) + result = await db.execute(select(User).where(User.email == email_clean)) + user = result.scalar_one_or_none() + if user: + if verify_password(credentials.password, user.hashed_password): + is_authenticated = True + + if not is_authenticated: + logger.warning(f"Login failed: Invalid credentials for '{email_clean}'") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password" ) - if not verify_password(credentials.password, user.hashed_password): - logger.warning(f"Login failed: Password mismatch for email '{email_clean}' (sent password length: {len(credentials.password)})") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password" - ) - if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -57,7 +92,8 @@ async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)): "id": user.id, "email": user.email, "full_name": user.full_name, - "role": user.role + "role": user.role, + "auth_source": user.auth_source } } diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..26794b8 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List + +from app.core.database import get_db +from app.core.security import get_password_hash +from app.models.models import User +from app.schemas.schemas import UserResponse, UserCreate, UserUpdate +from app.api.deps import get_current_user, require_admin + +router = APIRouter(prefix="/users", tags=["Users Management"], dependencies=[Depends(require_admin)]) + +@router.get("", response_model=List[UserResponse]) +async def list_users(db: AsyncSession = Depends(get_db)): + """Lists all user accounts in the database.""" + result = await db.execute(select(User).order_by(User.id.asc())) + users = result.scalars().all() + return users + +@router.post("", response_model=UserResponse) +async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)): + """Creates a new user account (Local or RADIUS).""" + # Check if email already registered + email_clean = payload.email.strip().lower() + result = await db.execute(select(User).where(User.email == email_clean)) + existing = result.scalar_one_or_none() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email address is already registered." + ) + + user = User( + email=email_clean, + hashed_password=get_password_hash(payload.password), + full_name=payload.full_name, + role=payload.role, + auth_source=payload.auth_source, + is_active=payload.is_active + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user(user_id: int, payload: UserUpdate, db: AsyncSession = Depends(get_db)): + """Updates an existing user account.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found." + ) + + # Prevent changing email of default admin + if user.email == "admin@oneverdrive.local" and payload.email and payload.email.strip().lower() != user.email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email address of the default system administrator cannot be changed." + ) + + if payload.email is not None: + email_clean = payload.email.strip().lower() + if email_clean != user.email: + existing_res = await db.execute(select(User).where(User.email == email_clean)) + if existing_res.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Target email address is already in use." + ) + user.email = email_clean + + if payload.password is not None and payload.password != "": + user.hashed_password = get_password_hash(payload.password) + + if payload.full_name is not None: + user.full_name = payload.full_name + + if payload.role is not None: + # Prevent demoting the main admin + if user.email == "admin@oneverdrive.local" and payload.role != "ADMIN": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Default system administrator role cannot be demoted." + ) + user.role = payload.role + + if payload.is_active is not None: + # Prevent deactivating the main admin + if user.email == "admin@oneverdrive.local" and not payload.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Default system administrator account cannot be deactivated." + ) + user.is_active = payload.is_active + + db.add(user) + await db.commit() + await db.refresh(user) + return user + +@router.delete("/{user_id}") +async def delete_user(user_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): + """Deletes a user account from the system.""" + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot delete your own account." + ) + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found." + ) + + if user.email == "admin@oneverdrive.local": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The default system administrator account cannot be deleted." + ) + + await db.delete(user) + await db.commit() + return {"message": f"User account '{user.email}' deleted successfully."} diff --git a/backend/app/core/radius.py b/backend/app/core/radius.py new file mode 100644 index 0000000..af7b9f8 --- /dev/null +++ b/backend/app/core/radius.py @@ -0,0 +1,112 @@ +import socket +import hashlib +import os +import logging + +logger = logging.getLogger("uvicorn.error") + +def authenticate_radius(username: str, password: str, server: str, secret: str, port: int = 1812, timeout: float = 3.0) -> bool: + """ + Performs RADIUS Access-Request authentication natively using raw UDP sockets. + Conforms to RFC 2865 standard for RADIUS protocol and PAP password encryption. + """ + if not server or not secret: + logger.error("RADIUS authentication failed: Server host or Shared Secret is not configured.") + return False + + try: + secret_bytes = secret.encode('utf-8') + + # Access-Request Header fields: + # Code: 1 (Access-Request) + # Identifier: 1 byte (random/sequential) + # Length: 2 bytes (20 + attributes length) + # Authenticator: 16 bytes (cryptographically strong random value) + identifier = os.urandom(1)[0] + authenticator = os.urandom(16) + + # Attribute 1: User-Name (Type 1) + user_bytes = username.encode('utf-8') + attr_username = bytes([1, len(user_bytes) + 2]) + user_bytes + + # Attribute 2: User-Password (Type 2, PAP Encryption) + # 1. Pad password with null bytes (\x00) to a multiple of 16 bytes + password_bytes = password.encode('utf-8') + pad_len = 16 - (len(password_bytes) % 16) + if pad_len == 16 and len(password_bytes) > 0: + pad_len = 0 + if pad_len > 0: + password_bytes += b'\x00' * pad_len + + # 2. Encrypt password chunks + # b(1) = MD5(Secret + Request Authenticator) + # c(1) = p(1) XOR b(1) + # b(2) = MD5(Secret + c(1)) + # c(2) = p(2) XOR b(2) + # and so on... + encrypted_password = b'' + last_chunk = authenticator + for i in range(0, len(password_bytes), 16): + chunk = password_bytes[i:i+16] + md5_hash = hashlib.md5(secret_bytes + last_chunk).digest() + encrypted_chunk = bytes(a ^ b for a, b in zip(chunk, md5_hash)) + encrypted_password += encrypted_chunk + last_chunk = encrypted_chunk + + attr_password = bytes([2, len(encrypted_password) + 2]) + encrypted_password + + # Combine attributes + attributes = attr_username + attr_password + packet_len = 20 + len(attributes) + + # Assemble complete packet + header = bytes([1, identifier, (packet_len >> 8) & 0xff, packet_len & 0xff]) + authenticator + packet = header + attributes + + # UDP Send/Receive + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + + logger.info(f"Sending RADIUS Access-Request to {server}:{port} for user '{username}'...") + sock.sendto(packet, (server, port)) + + response, _ = sock.recvfrom(4096) + if len(response) < 20: + logger.warning("RADIUS response packet is too short.") + return False + + resp_code = response[0] + resp_identifier = response[1] + resp_length = (response[2] << 8) + response[3] + resp_authenticator = response[4:20] + + # Validate Identifier match + if resp_identifier != identifier: + logger.warning(f"RADIUS response identifier mismatch (sent {identifier}, received {resp_identifier}).") + return False + + # Validate Response Authenticator: + # Response Authenticator = MD5(Code + ID + Length + Request Authenticator + Attributes + Secret) + resp_attributes = response[20:resp_length] + calc_auth = hashlib.md5(response[0:4] + authenticator + resp_attributes + secret_bytes).digest() + + if calc_auth != resp_authenticator: + logger.warning("RADIUS response authenticator signature validation failed (spoofing check).") + return False + + if resp_code == 2: + logger.info(f"RADIUS Access-Accept received for user '{username}'. Authentication successful!") + return True + elif resp_code == 3: + logger.warning(f"RADIUS Access-Reject received for user '{username}'.") + return False + else: + logger.warning(f"RADIUS server returned unknown response code: {resp_code}") + return False + + except socket.timeout: + logger.error(f"RADIUS authentication timed out (server {server}:{port} unreachable).") + return False + except Exception as e: + logger.error(f"RADIUS authentication system exception: {e}") + return False diff --git a/backend/app/main.py b/backend/app/main.py index f36229e..69b8570 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.api.backups import router as backups_router from app.api.events import router as events_router from app.api.stats import router as stats_router from app.api.settings import router as settings_router +from app.api.users import router as users_router from app.ws.manager import ws_manager @asynccontextmanager @@ -85,6 +86,7 @@ app.include_router(backups_router, prefix=settings.API_V1_PREFIX) app.include_router(events_router, prefix=settings.API_V1_PREFIX) app.include_router(stats_router, prefix=settings.API_V1_PREFIX) app.include_router(settings_router, prefix=settings.API_V1_PREFIX) +app.include_router(users_router, prefix=settings.API_V1_PREFIX) @app.websocket("/ws/telemetry") async def websocket_telemetry(websocket: WebSocket): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 521237f..3ed48e6 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -19,6 +19,7 @@ class User(Base): full_name = Column(String(255), nullable=True) role = Column(String(50), default="ADMIN", nullable=False) # ADMIN, OPERATOR, VIEWER is_active = Column(Boolean, default=True, nullable=False) + auth_source = Column(String(50), default="local", nullable=False) # local, radius created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False) class Client(Base): diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 0364675..7a28aa1 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -18,10 +18,26 @@ class UserResponse(BaseModel): full_name: Optional[str] role: str is_active: bool + auth_source: str created_at: datetime model_config = {"from_attributes": True} +class UserCreate(BaseModel): + email: str + password: str + full_name: Optional[str] = None + role: str = "OPERATOR" + is_active: bool = True + auth_source: str = "local" + +class UserUpdate(BaseModel): + email: Optional[str] = None + password: Optional[str] = None + full_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + # --- Client Schemas --- class RegistrationCodeCreate(BaseModel): client_name_hint: Optional[str] = None diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index ea4378a..0b41320 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -12,6 +12,10 @@ DEFAULT_SETTINGS = { "default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY), "default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY), "default_keep_monthly": str(settings.DEFAULT_RETENTION_MONTHLY), + "auth_mode": "local", + "radius_host": "", + "radius_port": "1812", + "radius_secret": "", } async def get_setting(db: AsyncSession, key: str) -> str: diff --git a/backend/migrate_users_auth.py b/backend/migrate_users_auth.py new file mode 100644 index 0000000..a3efdb0 --- /dev/null +++ b/backend/migrate_users_auth.py @@ -0,0 +1,23 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db") + +def migrate(): + print(f"Connecting to database at {DB_PATH}...") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Add auth_source column to users table + try: + cursor.execute("ALTER TABLE users ADD COLUMN auth_source VARCHAR(50) NOT NULL DEFAULT 'local'") + print("Column 'auth_source' added successfully to users.") + except sqlite3.OperationalError as e: + print(f"Column 'auth_source' could not be added (maybe it already exists?): {e}") + + conn.commit() + conn.close() + print("Migration finished.") + +if __name__ == "__main__": + migrate() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a20d37f..32bab56 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { RestoreView } from './pages/RestoreView'; import { EventsView } from './pages/EventsView'; import { LoginView } from './pages/LoginView'; import { SettingsView } from './pages/SettingsView'; +import { UsersView } from './pages/UsersView'; import { ActiveUpload } from './components/LiveTransferMeter'; import { api, @@ -152,6 +153,8 @@ export const App: React.FC = () => { return 'Explorador & Restore'; case 'events': return 'Auditoría & Logs'; + case 'users': + return 'Gestión de Cuentas'; case 'settings': return 'Configuración Global'; default: @@ -214,6 +217,10 @@ export const App: React.FC = () => { /> )} + {currentTab === 'users' && ( + + )} + {currentTab === 'settings' && ( = ({ currentTab, setCurrentTab, isW { id: 'clients', label: 'Clientes Windows', icon: HardDrive }, { id: 'jobs', label: 'Trabajos de Backup', icon: Layers }, { id: 'restore', label: 'Explorador & Restore', icon: RotateCcw }, + { id: 'users', label: 'Gestión de Cuentas', icon: Users }, { id: 'events', label: 'Auditoría & Logs', icon: FileText }, { id: 'settings', label: 'Configuración Global', icon: Settings }, ]; diff --git a/frontend/src/pages/SettingsView.tsx b/frontend/src/pages/SettingsView.tsx index 82692f3..518f6f0 100644 --- a/frontend/src/pages/SettingsView.tsx +++ b/frontend/src/pages/SettingsView.tsx @@ -34,7 +34,11 @@ export const SettingsView: React.FC = ({ onRefresh }) => { default_client_quota_gb: 100, default_keep_daily: 7, default_keep_weekly: 4, - default_keep_monthly: 12 + default_keep_monthly: 12, + auth_mode: 'local', + radius_host: '', + radius_port: 1812, + radius_secret: '' }); const fetchSettings = async () => { @@ -213,6 +217,66 @@ export const SettingsView: React.FC = ({ onRefresh }) => { + {/* Section 4: RADIUS Authentication Settings */} +
+
+ +

4. Autenticación Global y RADIUS

+
+

+ Elige el mecanismo de autenticación del panel de control. Si seleccionas RADIUS, los usuarios externos podrán loguearse con sus credenciales corporativas (JIT auto-provisioning). +

+ +
+ + +
+ + {formData.auth_mode === 'radius' && ( +
+
+ + setFormData({ ...formData, radius_host: e.target.value })} + /> +
+
+ + setFormData({ ...formData, radius_port: Number(e.target.value) })} + /> +
+
+ + setFormData({ ...formData, radius_secret: e.target.value })} + /> +
+
+ )} +
+ {/* Form Actions */}
+
+ + {loading && users.length === 0 ? ( +
Cargando usuarios...
+ ) : ( +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
Email / NombreRolMétodo de AutenticaciónEstadoFecha de RegistroAcciones
+
+ {user.email} + + {user.full_name || 'Sin nombre cargado'} + +
+
+ + {user.role} + + + + {user.auth_source === 'radius' ? 'RADIUS Externo' : 'Base Local'} + + + + {user.is_active ? 'Activo' : 'Inactivo'} + + {new Date(user.created_at).toLocaleDateString()} +
+ + +
+
+
+ )} + + {/* Create / Edit User Modal */} + {showModal && ( +
+
+
+

+ {editingUserId ? 'Editar Usuario' : 'Crear Nuevo Usuario'} +

+ +
+ +
+
+
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, full_name: e.target.value })} + /> +
+ + {!editingUserId && ( +
+ + +
+ )} + + {/* Show password field only for local users OR when creating a new user */} + {(formData.auth_source === 'local' || !editingUserId) && ( +
+ + setFormData({ ...formData, password: e.target.value })} + /> + {formData.auth_source === 'radius' && ( + + + Para RADIUS, la clave local sólo sirve de respaldo si RADIUS falla. + + )} +
+ )} + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ )} + + ); +}; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d04c2ee..d5a146f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -109,6 +109,20 @@ export interface SystemSettingsResponse { default_keep_daily: number; default_keep_weekly: number; default_keep_monthly: number; + auth_mode?: string; + radius_host?: string; + radius_port?: number; + radius_secret?: string; +} + +export interface UserItem { + id: number; + email: string; + full_name?: string; + role: string; + is_active: boolean; + auth_source: string; + created_at: string; } export const getAuthToken = (): string | null => { @@ -287,4 +301,32 @@ export const api = { // Events getEvents: (limit: number = 50) => request(`/events?limit=${limit}`), + + // Users Management + getUsers: () => request('/users'), + createUser: (userData: { + email: string; + password?: string; + full_name?: string; + role: string; + is_active: boolean; + auth_source: string; + }) => + request('/users', { + method: 'POST', + body: JSON.stringify(userData), + }), + updateUser: (userId: number, userData: Partial<{ + email: string; + password?: string; + full_name?: string; + role: string; + is_active: boolean; + }>) => + request(`/users/${userId}`, { + method: 'PUT', + body: JSON.stringify(userData), + }), + deleteUser: (userId: number) => + request<{ message: string }>(`/users/${userId}`, { method: 'DELETE' }), }; diff --git a/tests/test_auth_radius.py b/tests/test_auth_radius.py new file mode 100644 index 0000000..3555acd --- /dev/null +++ b/tests/test_auth_radius.py @@ -0,0 +1,140 @@ +import socket +import threading +import pytest +from datetime import datetime, timezone +import hashlib +from app.core.radius import authenticate_radius +from app.core.database import AsyncSessionLocal, init_db +from app.models.models import User, SystemSetting +from app.api.auth import login +from app.schemas.schemas import LoginRequest +from sqlalchemy import select + +class MockRadiusServer: + def __init__(self, host="127.0.0.1", port=18120, secret="my-secret"): + self.host = host + self.port = port + self.secret = secret + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind((self.host, self.port)) + self.running = False + + def start(self): + self.running = True + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def stop(self): + self.running = False + self.sock.close() + + def _run(self): + while self.running: + try: + data, addr = self.sock.recvfrom(4096) + if len(data) < 20: + continue + code = data[0] + identifier = data[1] + length = (data[2] << 8) + data[3] + req_authenticator = data[4:20] + attributes = data[20:length] + + # Parse Attributes + idx = 0 + username = "" + password = "" + while idx < len(attributes): + attr_type = attributes[idx] + attr_len = attributes[idx+1] + attr_val = attributes[idx+2 : idx+attr_len] + if attr_type == 1: + username = attr_val.decode('utf-8') + elif attr_type == 2: + # Decrypt PAP password + decrypted = b'' + last_chunk = req_authenticator + secret_bytes = self.secret.encode('utf-8') + for k in range(0, len(attr_val), 16): + chunk = attr_val[k:k+16] + md5_hash = hashlib.md5(secret_bytes + last_chunk).digest() + dec_chunk = bytes(a ^ b for a, b in zip(chunk, md5_hash)) + decrypted += dec_chunk + last_chunk = chunk + password = decrypted.decode('utf-8').rstrip('\x00') + idx += attr_len + + # Verify password. Let's make "radiuspass" the valid password + response_code = 3 # Access-Reject + if username == "radiususer" and password == "radiuspass": + response_code = 2 # Access-Accept + + # Build response packet + # Response Authenticator = MD5(Code + ID + Length + Request Authenticator + Attributes + Secret) + resp_length = 20 + header = bytes([response_code, identifier, (resp_length >> 8) & 0xff, resp_length & 0xff]) + resp_authenticator = hashlib.md5(header + req_authenticator + self.secret.encode('utf-8')).digest() + + response_packet = header[0:4] + resp_authenticator + self.sock.sendto(response_packet, addr) + except Exception: + break + +def test_radius_authentication_success_and_fail(): + server = MockRadiusServer(secret="testing-secret") + server.start() + try: + # 1. Access-Accept + ok = authenticate_radius("radiususer", "radiuspass", "127.0.0.1", "testing-secret", port=18120, timeout=1.0) + assert ok is True + + # 2. Access-Reject (wrong password) + ok = authenticate_radius("radiususer", "wrongpass", "127.0.0.1", "testing-secret", port=18120, timeout=1.0) + assert ok is False + + # 3. Access-Reject (wrong secret) + ok = authenticate_radius("radiususer", "radiuspass", "127.0.0.1", "bad-secret", port=18120, timeout=1.0) + assert ok is False + finally: + server.stop() + +@pytest.mark.asyncio +async def test_radius_login_jit_provisioning(): + """ + Tests RADIUS login flow: JIT creation of a local User when RADIUS Access-Accept succeeds. + """ + await init_db() + server = MockRadiusServer(secret="testing-secret") + server.start() + + try: + async with AsyncSessionLocal() as db: + # Configure global settings to RADIUS authentication + db.add(SystemSetting(key="auth_mode", value="radius")) + db.add(SystemSetting(key="radius_host", value="127.0.0.1")) + db.add(SystemSetting(key="radius_port", value="18120")) + db.add(SystemSetting(key="radius_secret", value="testing-secret")) + await db.commit() + + # Execute API login for 'radiususer@oneverdrive.local' with 'radiuspass' + login_req = LoginRequest(email="radiususer@oneverdrive.local", password="radiuspass") + res = await login(credentials=login_req, db=db) + + assert res["access_token"] is not None + assert res["user"]["email"] == "radiususer@oneverdrive.local" + assert res["user"]["role"] == "OPERATOR" + assert res["user"]["auth_source"] == "radius" + + # Check that user was saved to the SQLite DB + stmt = select(User).where(User.email == "radiususer@oneverdrive.local") + user_db = (await db.execute(stmt)).scalar_one_or_none() + assert user_db is not None + assert user_db.auth_source == "radius" + assert user_db.is_active is True + + # Revert global settings by deleting test configuration keys + from sqlalchemy import delete + await db.execute(delete(SystemSetting).where(SystemSetting.key.in_(["auth_mode", "radius_host", "radius_port", "radius_secret"]))) + await db.commit() + finally: + server.stop() diff --git a/windows-agent/agent/service.py b/windows-agent/agent/service.py index 4e4f64d..fdde81b 100644 --- a/windows-agent/agent/service.py +++ b/windows-agent/agent/service.py @@ -436,11 +436,24 @@ class AgentDaemon: if should_copy_local: dest_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(filepath, dest_path) - logger.info(f"Successfully copied {filepath.name} to local dest {dest_path}") - if not cloud_uploaded: - files_copied += 1 - bytes_transferred += file_size_bytes + # Robust write pattern inspired by Karen's Replicator 3.5.0 + temp_dest = dest_path.with_suffix(dest_path.suffix + ".tmp") + try: + shutil.copy2(filepath, temp_dest) + if dest_path.exists(): + dest_path.unlink() + temp_dest.rename(dest_path) + logger.info(f"Successfully copied {filepath.name} to local dest {dest_path}") + if not cloud_uploaded: + files_copied += 1 + bytes_transferred += file_size_bytes + except Exception as copy_err: + if temp_dest.exists(): + try: + temp_dest.unlink() + except Exception: + pass + raise copy_err except Exception as ex: logger.error(f"Failed to copy {filepath.name} to local path {dest_dir}: {str(ex)}") errors_count += 1