From 5478a88f2bc05dc7c1d3bc79f6afa681a86500a8 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Sat, 7 Mar 2026 23:09:13 -0600 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- backend/api/v1/modules/core/auth/dto.py | 48 +- backend/api/v1/modules/core/auth/routes.py | 40 +- backend/api/v1/modules/core/auth/service.py | 294 +++++++++++- frontend/src/lib/components/login-form.svelte | 423 ++++++++++++------ .../lib/components/sidebar/app-sidebar.svelte | 3 +- .../lib/components/sidebar/nav-user.svelte | 68 ++- .../ui/dropdown-menu/dropdown-menu-sub.svelte | 2 +- .../auth/switch-tenant/+server.ts | 58 +++ .../src/routes/dashboard/+layout.server.ts | 25 +- frontend/src/routes/dashboard/+layout.svelte | 1 + frontend/src/routes/dashboard/+layout.ts | 3 +- frontend/src/routes/login/+page.server.ts | 2 +- frontend/src/routes/login/+page.svelte | 2 +- frontend/static/login-bg.jpg | Bin 0 -> 58394 bytes 14 files changed, 799 insertions(+), 170 deletions(-) create mode 100644 frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts create mode 100644 frontend/static/login-bg.jpg diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index d103647d..16eedac7 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -12,7 +12,9 @@ class LoginRequestDTO(BaseModel): username: str = Field(..., description="Usuario o email") password: str = Field(..., min_length=6, description="Contraseña") - tenant_slug: str = Field(..., description="Slug del tenant") + # Opcional en el primer paso: si no se provee, el backend verifica credenciales + # y devuelve la lista de tenants disponibles en lugar de tokens. + tenant_slug: Optional[str] = Field(None, description="Slug del tenant") class Config: json_schema_extra = { @@ -57,6 +59,7 @@ class UserInfoResponseDTO(BaseModel): name: Optional[str] = None preferred_username: Optional[str] = None tenant_id: Optional[int] = None + tenant_slug: Optional[str] = None roles: list[str] = [] class Config: @@ -146,10 +149,49 @@ class SetCookieRequestDTO(BaseModel): access_token: str = Field(..., description="Access token JWT") refresh_token: str = Field(..., description="Refresh token JWT") + +class SwitchTenantRequestDTO(BaseModel): + """DTO para cambiar de tenant estando autenticado""" + + tenant_slug: str = Field(..., description="Slug del tenant destino") + refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens") + + +class DiscoverTenantsRequestDTO(BaseModel): + """DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente""" + + username: str = Field(..., description="Nombre de usuario o email") + class Config: json_schema_extra = { "example": { - "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "username": "jperez", } } + + +class TenantInfoDTO(BaseModel): + """Información básica de un tenant para mostrar en el selector de login""" + + id: int + name: str + slug: str + + class Config: + from_attributes = True + + +class DiscoverTenantsResponseDTO(BaseModel): + """Respuesta con los tenants disponibles para un usuario""" + + tenants: list[TenantInfoDTO] + + +class LoginChoiceResponseDTO(BaseModel): + """ + Respuesta del login cuando el usuario pertenece a varios tenants. + Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug. + """ + + status: str = "choose_tenant" + tenants: list[TenantInfoDTO] diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 949855b8..fbb9a69a 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -10,12 +10,14 @@ from sqlalchemy.orm import Session from .dto import ( ExchangeCodeRequestDTO, + LoginChoiceResponseDTO, LoginRequestDTO, LogoutRequestDTO, RefreshTokenRequestDTO, RegisterRequestDTO, RegisterResponseDTO, SetCookieRequestDTO, + SwitchTenantRequestDTO, TokenResponseDTO, UserInfoResponseDTO, ) @@ -49,7 +51,7 @@ async def register( return service.register(register_data) -@router.post("/login", response_model=TokenResponseDTO) +@router.post("/login", response_model=None) async def login( login_data: LoginRequestDTO, request: Request, # Inject Request @@ -73,6 +75,42 @@ async def login( ) +@router.post("/switch-tenant", response_model=TokenResponseDTO) +async def switch_tenant( + data: SwitchTenantRequestDTO, + db: Session = Depends(get_core_db), + credentials: HTTPAuthorizationCredentials = Depends(security), +): + """ + Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT. + + Requiere: + - Authorization: Bearer (para identificar al usuario) + - Body: { tenant_slug, refresh_token } + """ + service = AuthService(db) + # Obtener info del usuario desde el access token actual + user_info = service.get_user_info(credentials.credentials) + + keycloak_user_id = user_info.sub + # El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual, + # pero lo más directo es dejar que Keycloak lo resuelva usando la config global. + # Todos los tenants comparten el mismo realm en esta arquitectura. + from api.v1.modules.core.tenants.models import Tenant + from core.database import get_core_db as _gcdb + # Obtener el realm del tenant destino (o default) + tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first() + if not tenant: + raise HTTPException(status_code=403, detail="Access denied") + + return service.switch_tenant( + keycloak_user_id=keycloak_user_id, + keycloak_realm=tenant.keycloak_realm, + tenant_slug=data.tenant_slug, + refresh_token=data.refresh_token, + ) + + @router.post("/refresh", response_model=TokenResponseDTO) async def refresh_token( refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db) diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 952b64e5..6a69f29a 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -43,32 +43,45 @@ class AuthService: login_data: LoginRequestDTO, ip_address: str = None, user_agent: str = None - ) -> TokenResponseDTO: + ): """ - Autentica usuario y obtiene tokens + Autentica usuario y obtiene tokens. + + Si se omite tenant_slug, verifica credenciales primero y devuelve + la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens. Args: - login_data: Credenciales de login + login_data: Credenciales de login (tenant_slug es opcional) ip_address: Dirección IP del cliente user_agent: User Agent del cliente Returns: - TokenResponseDTO con access_token y refresh_token + TokenResponseDTO si tenant_slug fue provisto, + LoginChoiceResponseDTO si no se proveyó tenant_slug. Raises: HTTPException: Si las credenciales son inválidas """ + # PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs + if not login_data.tenant_slug: + from .dto import LoginChoiceResponseDTO, TenantInfoDTO + tenants = self._verify_credentials_and_list_tenants( + login_data.username, login_data.password + ) + # Siempre devolver LoginChoiceResponseDTO; el frontend decide si + # auto-seleccionar (1 tenant) o mostrar selector (>1 tenants). + return LoginChoiceResponseDTO( + tenants=[TenantInfoDTO(**t) for t in tenants] + ) + try: # Verificar que el tenant existe tenant_service = TenantService(self.db) user_tenant_service = UserTenantService(self.db) tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug) - if not tenant: - raise HTTPException(status_code=404, detail="Tenant not found") - - if not tenant.is_active: - raise HTTPException(status_code=403, detail="Tenant is not active") + if not tenant or not tenant.is_active: + raise HTTPException(status_code=401, detail="Invalid credentials") # Crear nueva instancia de KeycloakOpenID con el realm del tenant keycloak_client = KeycloakOpenID( @@ -110,8 +123,8 @@ class AuthService: f"User {user_id} tried to access tenant {tenant.id} without permission" ) raise HTTPException( - status_code=403, - detail="You don't have access to this tenant", + status_code=401, + detail="Invalid credentials", ) # Obtener los datos actuales del usuario @@ -228,17 +241,25 @@ class AuthService: if "realm_access" in user_info: roles = user_info["realm_access"].get("roles", []) - # Extraer tenant_id si está presente + # Extraer tenant_id y tenant_slug si están presentes tenant_id = user_info.get("tenant_id") if not tenant_id and "attributes" in user_info: tenant_id = user_info["attributes"].get("tenant_id") + tenant_slug = user_info.get("tenant_slug") + if not tenant_slug and "attributes" in user_info: + tenant_slug = user_info["attributes"].get("tenant_slug") + # Puede venir como lista de Keycloak attributes + if isinstance(tenant_slug, list): + tenant_slug = tenant_slug[0] if tenant_slug else None + return UserInfoResponseDTO( sub=user_info.get("sub"), email=user_info.get("email"), name=user_info.get("name"), preferred_username=user_info.get("preferred_username"), tenant_id=int(tenant_id) if tenant_id else None, + tenant_slug=tenant_slug, roles=roles, ) @@ -455,3 +476,252 @@ class AuthService: except Exception as e: logger.error(f"Code exchange error: {str(e)}") raise HTTPException(status_code=500, detail="Code exchange error") + + def switch_tenant( + self, + keycloak_user_id: str, + keycloak_realm: str, + tenant_slug: str, + refresh_token: str, + ) -> TokenResponseDTO: + """ + Cambia el tenant activo de un usuario autenticado sin requerir su contraseña. + + Pasos: + 1. Verifica que el tenant existe y está activo. + 2. Verifica que el usuario tiene acceso a ese tenant. + 3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak. + 4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados. + """ + from api.v1.modules.core.tenants.models import Tenant + + tenant_service = TenantService(self.db) + user_tenant_service = UserTenantService(self.db) + + tenant = tenant_service.get_tenant_by_slug(tenant_slug) + if not tenant or not tenant.is_active: + raise HTTPException(status_code=403, detail="Access denied") + + # Verificar acceso + has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id) + if not has_access: + raise HTTPException(status_code=403, detail="Access denied") + + # Actualizar atributos en Keycloak antes de emitir el nuevo token + try: + keycloak_admin = KeycloakAdmin( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=keycloak_realm, + user_realm_name="master", + verify=True, + ) + current_user = keycloak_admin.get_user(keycloak_user_id) + attrs = current_user.get("attributes", {}) + attrs["tenant_id"] = [str(tenant.id)] + attrs["tenant_slug"] = [tenant.slug] + keycloak_admin.update_user( + user_id=keycloak_user_id, + payload={ + "email": current_user.get("email"), + "firstName": current_user.get("firstName"), + "lastName": current_user.get("lastName"), + "enabled": current_user.get("enabled", True), + "emailVerified": current_user.get("emailVerified", False), + "attributes": attrs, + }, + ) + except KeycloakError as e: + logger.warning(f"switch_tenant: could not update user attributes: {e}") + raise HTTPException(status_code=500, detail="Could not update tenant attributes") + + # Emitir nuevos tokens usando el refresh_token existente + keycloak_client = KeycloakOpenID( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + client_id=settings.KEYCLOAK_CLIENT_ID, + realm_name=keycloak_realm, + client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, + ) + try: + token_response = keycloak_client.refresh_token(refresh_token) + except KeycloakError as e: + logger.warning(f"switch_tenant: token refresh failed: {e}") + raise HTTPException(status_code=401, detail="Token refresh failed; please log in again") + + return TokenResponseDTO( + access_token=token_response["access_token"], + refresh_token=token_response["refresh_token"], + token_type="bearer", + expires_in=token_response["expires_in"], + ) + + def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list: + """ + Verifica las credenciales del usuario contra Keycloak y, solo si son válidas, + devuelve la lista de tenants a los que tiene acceso. + + Esto evita el oráculo de enumeración de usuarios del antiguo endpoint + /discover-tenants que no requería contraseña. + + Args: + username: Nombre de usuario o email + password: Contraseña en texto plano + + Returns: + Lista de dicts {id, name, slug} con los tenants del usuario + + Raises: + HTTPException 401: Si las credenciales son inválidas + """ + from api.v1.modules.core.tenants.models import Tenant + from api.v1.modules.core.user_tenant.models import UserTenant + from sqlalchemy import and_ + + tenants = self.db.query(Tenant).filter(Tenant.is_active).all() + if not tenants: + raise HTTPException(status_code=401, detail="Invalid credentials") + + realms: dict[str, list] = {} + for tenant in tenants: + realms.setdefault(tenant.keycloak_realm, []).append(tenant) + + credentials_verified = False + matched_tenants = [] + + for realm_name, realm_tenants in realms.items(): + try: + keycloak_admin = KeycloakAdmin( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=realm_name, + user_realm_name="master", + verify=True, + ) + + users = keycloak_admin.get_users({"username": username, "exact": True}) + if not users: + users = keycloak_admin.get_users({"email": username, "exact": True}) + if not users: + continue + + keycloak_user_id = users[0]["id"] + + # Verificar la contraseña contra este realm (una sola vez) + if not credentials_verified: + keycloak_client = KeycloakOpenID( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + client_id=settings.KEYCLOAK_CLIENT_ID, + realm_name=realm_name, + client_secret_key=settings.KEYCLOAK_CLIENT_SECRET, + ) + try: + keycloak_client.token( + username=username, + password=password, + grant_type=["password"], + ) + credentials_verified = True + except KeycloakError: + # Contraseña incorrecta — no revelar que el usuario existe + raise HTTPException(status_code=401, detail="Invalid credentials") + + # Recopilar tenants con acceso confirmado + for tenant in realm_tenants: + has_access = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant.id, + UserTenant.is_active, + ) + ) + .first() + ) + if has_access: + matched_tenants.append( + {"id": tenant.id, "name": tenant.name, "slug": tenant.slug} + ) + + except HTTPException: + raise + except Exception as e: + logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}") + continue + + if not credentials_verified: + raise HTTPException(status_code=401, detail="Invalid credentials") + + return matched_tenants + + def discover_user_tenants(self, username: str) -> list: + """ + [DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar. + Descubre los tenants activos a los que pertenece un usuario dado su username. + """ + from api.v1.modules.core.tenants.models import Tenant + from api.v1.modules.core.user_tenant.models import UserTenant + from sqlalchemy import and_ + + # 1. Obtener todos los tenants activos + tenants = self.db.query(Tenant).filter(Tenant.is_active).all() + + if not tenants: + return [] + + # 2. Agrupar tenants por keycloak_realm para no repetir consultas admin + realms: dict[str, list] = {} + for tenant in tenants: + realms.setdefault(tenant.keycloak_realm, []).append(tenant) + + matched_tenants = [] + + for realm_name, realm_tenants in realms.items(): + try: + keycloak_admin = KeycloakAdmin( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=realm_name, + user_realm_name="master", + verify=True, + ) + + # Buscar por username exacto + users = keycloak_admin.get_users({"username": username, "exact": True}) + if not users: + # Intentar por email + users = keycloak_admin.get_users({"email": username, "exact": True}) + + if not users: + continue + + keycloak_user_id = users[0]["id"] + + # 3. Para cada tenant en este realm, verificar UserTenant + for tenant in realm_tenants: + has_access = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant.id, + UserTenant.is_active, + ) + ) + .first() + ) + if has_access: + matched_tenants.append( + {"id": tenant.id, "name": tenant.name, "slug": tenant.slug} + ) + + except Exception as e: + logger.warning( + f"Could not query realm '{realm_name}' during tenant discovery: {e}" + ) + continue + + return matched_tenants diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index ebb3726a..80d0493e 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -5,17 +5,16 @@ Field, FieldLabel, FieldDescription, - FieldSeparator, } from "$lib/components/ui/field/index.js"; import { Input } from "$lib/components/ui/input/index.js"; import { Button } from "$lib/components/ui/button/index.js"; import { cn } from "$lib/utils.js"; - import { FileText, ShieldCheck } from 'lucide-svelte'; + import faviconUrl from '$lib/assets/favicon.svg'; import type { HTMLAttributes } from "svelte/elements"; import { page } from '$app/state'; import { enhance } from '$app/forms'; import { loginWithProvider } from '$lib/sso'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; let { class: className, ...restProps }: HTMLAttributes = $props(); @@ -23,14 +22,20 @@ let username = $state('demo'); let password = $state('demo123'); - let tenantSlug = $state('aduanasoft'); + let tenantSlug = $state(''); let loading = $state(false); - - // Obtener el error del servidor si existe + // step 1 = credenciales, step 2 = selección de organización + let step = $state<1 | 2>(1); + let readyToSubmit = $state(false); + let formEl: HTMLFormElement | undefined = $state(); + + // Descubrimiento de tenants + type TenantInfo = { id: number; name: string; slug: string }; + let tenants = $state([]); + const error = $derived(page.form?.error || ''); // Limpiar todo el localStorage y cookies al montar el componente de login - // Esto asegura que no queden datos del tenant anterior onMount(() => { clearAllData(); }); @@ -46,59 +51,100 @@ } } - // Función para limpiar todo el localStorage y cookies function clearAllData() { if (typeof localStorage !== 'undefined') { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('activeCompanyId'); } - clearClientCookies(); } + + async function fetchTenants(): Promise { + try { + const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); + // Llama a /login SIN tenant_slug: el backend verifica credenciales primero, + // luego devuelve las orgs. Sin contraseña válida no se revela nada. + const res = await fetch(`${apiBase}/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (res.ok) { + const data = await res.json(); + // { status: "choose_tenant", tenants: [...] } + if (data.tenants) return data.tenants; + } + } catch { + // ignore — el server action mostrará el error de autenticación + } + return []; + } + + // Llama al backend real desde el paso 2 + function confirmTenant() { + if (!tenantSlug) return; + readyToSubmit = true; + formEl?.requestSubmit(); + } + + function goBack() { + step = 1; + tenantSlug = ''; + tenants = []; + readyToSubmit = false; + } function handleMicrosoftLogin() { - // Limpiar datos antes de iniciar SSO clearAllData(); - - // Guardar el tenant_slug en localStorage para recuperarlo después del callback - if (tenantSlug) { - localStorage.setItem('pending_tenant_slug', tenantSlug); - } + if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug); loginWithProvider('microsoft'); } function handleGoogleLogin() { - // Limpiar datos antes de iniciar SSO clearAllData(); - - // Guardar el tenant_slug en localStorage para recuperarlo después del callback - if (tenantSlug) { - localStorage.setItem('pending_tenant_slug', tenantSlug); - } + if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug); loginWithProvider('google'); } - - function handleAppleLogin() { - // Apple no está configurado por defecto en Keycloak, - // pero puedes agregarlo siguiendo el mismo patrón - alert('Apple SSO no está configurado aún'); - }
- + +
{ + use:enhance={async ({ cancel }) => { + // Interceptar solo en el paso 1 antes de hacer el submit real + if (!readyToSubmit) { + cancel(); + loading = true; + tenants = await fetchTenants(); + loading = false; + if (tenants.length === 1) { + // 1 sola org: login directo + tenantSlug = tenants[0].slug; + readyToSubmit = true; + await tick(); // esperar a que el DOM refleje tenantSlug antes de enviar + formEl?.requestSubmit(); + } else if (tenants.length > 1) { + // Varias orgs: mostrar selector + step = 2; + } else { + // 0 orgs: enviar igual, el backend rechazará + readyToSubmit = true; + await tick(); + formEl?.requestSubmit(); + } + return; + } loading = true; return async ({ update, result }) => { await update(); loading = false; - - // Si hay un error, limpiar cookies del cliente + readyToSubmit = false; if (result.type === 'failure') { clearClientCookies(); } @@ -106,131 +152,218 @@ }} > -
-

Anexo 76

-

- Sistema de Cumplimiento Fiscal y Aduanal -

+ +
+ Anexo 76 +
+

Anexo 76

+

+ Sistema de Cumplimiento Fiscal y Aduanal +

+
- + {#if error} -
+
+ + + {error}
{/if} - - - Tenant - - - - - Usuario - - - -
- Contraseña - - ¿Olvidaste tu contraseña? - + + + + {#if step === 2} + + + {/if} + + {#if step === 1} + + + Usuario + + + + + + + + + + + + +
+
+ o continúa con +
- - - - - - - O continua con - - - + + + +

+ ¿No tienes cuenta?{' '} + + Regístrate + +

+ {:else} + +
+

Selecciona tu organización

+

Tu cuenta tiene acceso a varias organizaciones

+
+ +
+ {#each tenants as t} + + {/each} +
+ + + + + + - - - - - ¿No tienes una cuenta? Regístrate - + Volver al inicio de sesión + + {/if} -