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).
+