From 52fec4038e1413f0bad783537af9d6efed87bbd5 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 7 May 2026 12:41:12 -0500 Subject: [PATCH] feat: implement lazy-link for pending invites and add hub_invite_token to InviteToken model Co-authored-by: Copilot --- ...d_add_hub_invite_token_to_invite_tokens.py | 26 ++++ backend/api/v1/modules/core/auth/routes.py | 21 +++ backend/api/v1/modules/core/auth/service.py | 126 +++++++++++++++++- backend/api/v1/modules/core/invites/models.py | 3 + .../api/v1/modules/core/invites/service.py | 52 ++++++-- frontend/src/routes/auth/sso/+page.server.ts | 34 ++++- .../src/routes/dashboard/users/+page.svelte | 4 +- frontend/src/routes/login/+page.server.ts | 6 +- frontend/src/routes/logout/+server.ts | 26 ++-- frontend/src/routes/register/+page.svelte | 2 +- 10 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 backend/alembic/versions/84daf7ae73cd_add_hub_invite_token_to_invite_tokens.py diff --git a/backend/alembic/versions/84daf7ae73cd_add_hub_invite_token_to_invite_tokens.py b/backend/alembic/versions/84daf7ae73cd_add_hub_invite_token_to_invite_tokens.py new file mode 100644 index 00000000..90e21df1 --- /dev/null +++ b/backend/alembic/versions/84daf7ae73cd_add_hub_invite_token_to_invite_tokens.py @@ -0,0 +1,26 @@ +"""add_hub_invite_token_to_invite_tokens + +Revision ID: 84daf7ae73cd +Revises: b2c3d4e5f6a7 +Create Date: 2026-05-07 14:33:14.793300 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '84daf7ae73cd' +down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'invite_tokens', + sa.Column('hub_invite_token', sa.String(length=255), nullable=True), + schema='core', + ) + + +def downgrade() -> None: + op.drop_column('invite_tokens', 'hub_invite_token', schema='core') diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index aaa7281d..65d35fa8 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -202,6 +202,27 @@ async def get_current_user_info( return await service.get_user_info(credentials.credentials) +@router.post("/lazy-link", status_code=200) +async def lazy_link( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_core_db), +): + """ + Vincula un invite pendiente al usuario autenticado (lazy-link). + Se llama después de un SSO login desde el workspace para crear el UserTenant + si hay un invite_token pendiente para el email del usuario. + """ + service = AuthService(db) + try: + await service._link_pending_invite( + credentials.credentials, # username_or_email = token (fallback) + access_token=credentials.credentials, + ) + except Exception: + pass + return {"ok": True} + + @router.post("/logout") async def logout( logout_data: LogoutRequestDTO, diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 0c2f6d72..c8c013b2 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -49,7 +49,12 @@ class AuthService: tenants=[TenantInfoDTO(**t) for t in data["tenants"]] ) - # Si devolvió tokens + # Si devolvió tokens — lazy-link: verificar si hay invite pendiente + try: + await self._link_pending_invite(login_data.username) + except Exception as exc: + logger.warning("Lazy-link invite check failed (non-blocking): %s", exc) + # AUDIT LOG: Login Success try: from api.v1.modules.a76.audit_log.services.service import AuditService @@ -335,3 +340,122 @@ class AuthService: except Exception as e: logger.error(f"SSO exchange error: {str(e)}") raise HTTPException(status_code=500, detail="SSO exchange error") + + async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None: + """ + Lazy-link: después de un login exitoso comprueba si existe un invite_token + pendiente para el email del usuario. Si lo hay, crea la fila UserTenant + y consume el token. + + Si se provee access_token, extrae hub_user_id y email directamente del JWT + sin necesidad de un lookup extra al Hub. + """ + from datetime import datetime, timezone + from api.v1.modules.core.invites.models import InviteToken + from api.v1.modules.core.tenants.models import Tenant + from api.v1.modules.core.user_tenant.models import UserTenant + + hub_user_id = None + user_email = username_or_email + + # Si tenemos el access_token, extraer info del JWT directamente + if access_token: + try: + from core.security import verify_token + claims = await verify_token(access_token) + hub_user_id = claims.get("sub") + user_email = claims.get("email") or username_or_email + except Exception as exc: + logger.debug("_link_pending_invite: JWT decode failed: %s", exc) + + # Sin access_token: buscar usuario en el Hub vía service account + if not hub_user_id: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if login_resp.status_code != 200: + return + svc_token = login_resp.json().get("access_token", "") + search_resp = await client.get( + f"{settings.HUB_URL}api/v1/hub/admins", + params={"email": username_or_email}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if search_resp.status_code == 200: + items = search_resp.json() + items = items if isinstance(items, list) else items.get("items", []) + matches = [ + u for u in items + if u.get("email", "").lower() == username_or_email.lower() + or u.get("username", "").lower() == username_or_email.lower() + ] + if matches: + hub_user_id = matches[0].get("id") + user_email = matches[0].get("email", username_or_email) + if not hub_user_id: + return + except Exception as exc: + logger.debug("_link_pending_invite: hub lookup failed: %s", exc) + return + + now = datetime.now(timezone.utc) + pending = ( + self.db.query(InviteToken) + .filter( + InviteToken.email == user_email, + InviteToken.used_at.is_(None), + InviteToken.expires_at > now, + ) + .first() + ) + if not pending: + return + + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == pending.tenant_slug) + .first() + ) + if not tenant: + logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug) + return + + # Evitar duplicados + existing = ( + self.db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == hub_user_id, + UserTenant.tenant_id == tenant.id, + ) + .first() + ) + if existing: + # Vincular existe, solo consumir el token + pending.used_at = now + self.db.commit() + return + + try: + ut = UserTenant( + keycloak_user_id=hub_user_id, + tenant_id=tenant.id, + company_id=pending.company_id, + role=pending.role, + is_active=True, + ) + self.db.add(ut) + pending.used_at = now + self.db.commit() + logger.info( + "Lazy-link: UserTenant created for user=%s tenant=%s role=%s", + hub_user_id, + tenant.slug, + pending.role, + ) + except Exception as exc: + logger.warning("_link_pending_invite: could not create UserTenant: %s", exc) + self.db.rollback() + diff --git a/backend/api/v1/modules/core/invites/models.py b/backend/api/v1/modules/core/invites/models.py index 2259bf1a..f0ea7623 100644 --- a/backend/api/v1/modules/core/invites/models.py +++ b/backend/api/v1/modules/core/invites/models.py @@ -37,3 +37,6 @@ class InviteToken(Base, BaseTimestampMixin): # Específico de Anexo76: empresa destino para crear UserTenant company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # Token generado en el Hub (para la URL de registro del workspace) + hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py index 03897646..4170a31a 100644 --- a/backend/api/v1/modules/core/invites/service.py +++ b/backend/api/v1/modules/core/invites/service.py @@ -27,6 +27,15 @@ def _hash_token(token_plain: str) -> str: return hashlib.sha256(token_plain.encode()).hexdigest() +def _extract_token_from_url(url: str) -> Optional[str]: + """Extract invite_token query param from a URL string.""" + from urllib.parse import urlparse, parse_qs + parsed = urlparse(url) + params = parse_qs(parsed.query) + tokens = params.get("invite_token", []) + return tokens[0] if tokens else None + + class InviteService: def __init__(self, db: Session): self.db = db @@ -38,6 +47,7 @@ class InviteService: tenant_slug: str, base_url: str, ) -> InviteResponseDTO: + import httpx from api.v1.modules.core.tenants.models import Tenant tenant = ( @@ -66,6 +76,38 @@ class InviteService: if not company_role: raise HTTPException(status_code=404, detail="Rol no encontrado") + # Crear invite en el Hub para que el usuario use el form del workspace + hub_invite_token: Optional[str] = None + invite_url: str = "" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if login_resp.status_code == 200: + svc_token = login_resp.json().get("access_token", "") + hub_resp = await client.post( + f"{settings.HUB_URL}api/v1/hub/invites", + json={"email": str(data.email), "tenant_slug": tenant_slug}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if hub_resp.status_code in (200, 201): + hub_data = hub_resp.json() + hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) + invite_url = hub_data.get("invite_url", "") + except Exception as exc: + logger.warning("Hub invite creation failed (non-blocking): %s", exc) + + # Fallback: URL local si el Hub falló + if not invite_url: + invite_url = ( + f"{base_url}/register" + f"?invite_token={token_plain}" + f"&tenant={tenant_slug}" + f"&email={data.email}" + ) + invite = InviteToken( token_hash=token_hash, tenant_slug=tenant_slug, @@ -74,19 +116,13 @@ class InviteService: created_by=created_by, expires_at=expires_at, company_id=data.company_id, + hub_invite_token=hub_invite_token, ) self.db.add(invite) self.db.commit() self.db.refresh(invite) - invite_url = ( - f"{base_url}/register" - f"?invite_token={token_plain}" - f"&tenant={tenant_slug}" - f"&email={data.email}" - ) - - # Enviar email (best-effort — si falla se loguea la URL para el admin) + # Enviar email (best-effort) try: await self._send_invite_email( to_email=str(data.email), diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 558676d3..a7978f7b 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -7,7 +7,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; // Disable client-side rendering to prevent SvelteKit from making a second // __data.json request that would consume the one-time relay token twice. @@ -21,12 +20,15 @@ export const load: PageServerLoad = async ({ url, cookies }) => { throw redirect(303, '/login?error=sso_missing_token'); } - // If there is already a valid session, skip the exchange to avoid - // re-using a one-time relay token (e.g. browser tab reload or prefetch). - const existingToken = getAccessTokenFromCookies(cookies); - if (existingToken) { - console.log('[SSO] sesión existente detectada, redirigiendo sin exchange'); - throw redirect(303, '/dashboard'); + // Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies. + // No se omite el exchange aunque haya token existente — la sesión podría ser + // de otro usuario (ej: juan que hace logout e ingresa como lal17). + // La única excepción es si el relay token ya fue consumido (lo maneja el error handler). + { + const { clearAccessTokenCookies } = await import('$lib/server/access-token-cookie'); + clearAccessTokenCookies(cookies); + cookies.delete('refresh_token', { path: '/' }); + cookies.delete('active_company_id', { path: '/' }); } // SSO exchange must call the Hub that GENERATED the relay token. @@ -124,5 +126,23 @@ export const load: PageServerLoad = async ({ url, cookies }) => { }); } console.log('[SSO] cookies configuradas, redirigiendo a /dashboard'); + + // Ejecutar lazy-link server-side: crear UserTenant si hay invite pendiente. + // Se llama con el Bearer token recién obtenido. Best-effort, no bloquea el SSO. + try { + const internalApiUrl = ( + process.env.INTERNAL_API_URL || + process.env.VITE_API_URL || + 'http://backend:8000/api/' + ).replace(/\/+$/, ''); + await fetch(`${internalApiUrl}/v1/auth/lazy-link`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + }, + }).catch(() => {}); + } catch { /* non-blocking */ } + throw redirect(303, '/dashboard'); }; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index d81a26f8..bad395ef 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1365,9 +1365,9 @@
{#if canCreate} - {/if}
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 6255426a..a96b687a 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -2,6 +2,8 @@ import { redirect, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { clearAuthTokens, setAuthTokens, getServerApiUrl } from '$lib/server/api'; + + export const load: PageServerLoad = async ({ cookies, url }) => { // Si hay un parámetro 'logout' en la URL, limpiar las cookies if (url.searchParams.has('logout')) { @@ -30,8 +32,8 @@ export const actions = { } try { - const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001'; - const loginUrl = `${hubUrl}/api/v1/auth/login`; + const apiUrl = getServerApiUrl(); + const loginUrl = `${apiUrl}v1/auth/login`; const requestBody = { username, diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index f8309a08..fbf58b2f 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -6,12 +6,9 @@ import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; export const POST: RequestHandler = async ({ cookies, request }) => { const refreshToken = cookies.get('refresh_token'); - // Post-logout siempre va al workspace login, no al login local de Anexo76. - // Desde el workspace el usuario puede volver a autenticarse con Microsoft - // y el relay lo traerá de vuelta automáticamente. - // HUB_URL es la URL pública del workspace (ej: https://workspace.aduanasoft.com) + // Redirigir al workspace (Hub) — es el sistema central de autenticación. const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, ''); - const workspaceLoginUrl = hubPublicUrl + const postLogoutUrl = hubPublicUrl ? `${hubPublicUrl}/login` : `${new URL(request.url).origin}/login`; @@ -22,29 +19,22 @@ export const POST: RequestHandler = async ({ cookies, request }) => { cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); - // Llamar al Hub para revocar el refresh token. - // La navegación final siempre debe volver al login local de anexo76 - // sin redirigir al endpoint de logout de Keycloak. + // Llamar al Hub para revocar el refresh token (best-effort). if (refreshToken) { try { const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); - - const res = await fetch(`${hubUrl}/api/v1/auth/logout`, { + await fetch(`${hubUrl}/api/v1/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken, - post_logout_redirect_uri: workspaceLoginUrl, + post_logout_redirect_uri: postLogoutUrl, }), }); - - if (res.ok) { - await res.json().catch(() => ({})); - } - } catch (err: any) { - // Si falla la llamada al Hub, caer al workspace login de todos modos + } catch { + // Si falla la llamada al Hub, continuar de todos modos } } - throw redirect(303, workspaceLoginUrl); + throw redirect(303, postLogoutUrl); }; diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte index bb7a4e19..128aeac7 100644 --- a/frontend/src/routes/register/+page.svelte +++ b/frontend/src/routes/register/+page.svelte @@ -184,7 +184,7 @@ - {#if success} + {:else if success}