erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/historias-usuario/US-FUND-001-autenticacion-basica-jwt.md

19 KiB
Raw Blame History

US-FUND-001: Autenticación básica con JWT para Construcción

Épica: MAI-001 - Fundamentos Sprint: Sprint 1 (Semanas 2-3) Story Points: 8 SP Presupuesto: $2,900 MXN Prioridad: P0 - Crítica (Alcance Inicial) Estado: 🚧 Planificado Reutilización GAMILIT: 95% (adaptación mínima)


📝 Descripción

Como cualquier usuario del sistema (director, ingeniero, residente, etc.), quiero poder registrarme, iniciar sesión con selector de constructora, y recuperar mi contraseña para acceder de forma segura a la plataforma y mis obras asignadas.

Contexto del Alcance Inicial: En el MVP se implementó un sistema de autenticación basado en GAMILIT con JWT que soporta 7 roles fijos específicos de construcción. El sistema incluye:

  • Multi-tenancy por constructora
  • Selector de constructora al login
  • Invitación de usuarios por constructora
  • Rol por defecto: resident

Diferencias vs GAMILIT:

  • GAMILIT: Auto-registro abierto → Inmobiliario: Registro por invitación
  • GAMILIT: 1 organización → Inmobiliario: Múltiples constructoras
  • GAMILIT: 3 roles → Inmobiliario: 7 roles

Criterios de Aceptación

  • CA-01: El sistema permite registrar nuevos usuarios por invitación (email único)
  • CA-02: Al registrarse por invitación, el usuario recibe rol especificado en la invitación (default: resident)
  • CA-03: Las contraseñas se almacenan hasheadas con bcrypt (min. 10 rounds)
  • CA-04: El login incluye selector de constructora (si usuario pertenece a múltiples)
  • CA-05: El login genera un JWT token válido por 24 horas
  • CA-06: El JWT incluye: userId, role, constructoraId (activa), email
  • CA-07: Existe endpoint de recuperación de contraseña que envía email con token temporal
  • CA-08: El token de recuperación expira en 1 hora
  • CA-09: El sistema permite cerrar sesión (invalidación de token en frontend)
  • CA-10: Las contraseñas deben tener mínimo 8 caracteres (al menos 1 número)
  • CA-11: Se retorna mensaje de error apropiado para credenciales inválidas
  • CA-12: Usuario puede cambiar de constructora activa sin volver a loggearse

🎯 Especificaciones Técnicas

Backend (Node.js + Express + TypeScript)

Endpoints:

POST /api/auth/register-by-invitation
- Body: { invitationToken, password, firstName, lastName }
- Response: { user, accessToken, constructora }
- Note: Valida token de invitación antes de crear usuario

POST /api/auth/login
- Body: { email, password, constructoraId? }
- Response: { user, accessToken, constructoras[] }
- Note: Si usuario tiene múltiples constructoras, retorna lista para selector

POST /api/auth/switch-constructora
- Body: { constructoraId }
- Headers: Authorization: Bearer {token}
- Response: { accessToken } (nuevo token con constructora actualizada)

POST /api/auth/forgot-password
- Body: { email }
- Response: { message: "Email sent" }

POST /api/auth/reset-password
- Body: { token, newPassword }
- Response: { message: "Password updated" }

GET /api/auth/me
- Headers: Authorization: Bearer {token}
- Response: { user, constructora, role }

Servicios:

  • AuthService: Lógica de autenticación (register, login, validateUser)
  • JwtService: Generación y validación de tokens JWT
  • MailService: Envío de emails de recuperación e invitación
  • ConstructoraService: Gestión de relación usuario-constructora

Entidades:

// apps/backend/src/modules/auth/entities/user.entity.ts
@Entity('users')
class User {
  id: string (UUID)
  email: string (unique)
  password: string (hashed)
  first_name: string
  last_name: string
  is_active: boolean
  email_verified: boolean
  createdAt: Date
  updatedAt: Date
}

// apps/backend/src/modules/auth/entities/user-constructora.entity.ts
@Entity('user_constructoras')
class UserConstructora {
  id: string (UUID)
  user_id: string (FK to users)
  constructora_id: string (FK to constructoras)
  role: ConstructionRole ('director' | 'engineer' | 'resident' | 'purchases' | 'finance' | 'hr' | 'post_sales')
  is_primary: boolean (constructora por defecto)
  active: boolean
  created_at: Date
}

// apps/backend/src/modules/auth/entities/invitation.entity.ts
@Entity('invitations')
class Invitation {
  id: string (UUID)
  constructora_id: string (FK)
  email: string
  role: ConstructionRole
  token: string (unique)
  expires_at: Date
  used: boolean
  invited_by: string (FK to users)
  created_at: Date
}

// apps/backend/src/modules/auth/entities/password-reset-token.entity.ts
@Entity('password_reset_tokens')
class PasswordResetToken {
  id: string (UUID)
  userId: string (FK to users)
  token: string
  expiresAt: Date
  used: boolean
}

Guards:

  • JwtAuthGuard: Protege rutas que requieren autenticación
  • RolesGuard: Valida roles específicos (7 roles de construcción)
  • ConstructoraGuard: Valida que usuario tenga acceso a la constructora

JWT Payload:

interface JwtPayload {
  sub: string; // userId
  email: string;
  role: ConstructionRole;
  constructoraId: string;
  iat: number;
  exp: number;
}

Frontend (React + Vite + TypeScript)

Componentes:

  • LoginForm.tsx: Formulario de inicio de sesión con selector de constructora
  • ConstructoraSelector.tsx: Selector de constructora (si usuario tiene múltiples)
  • RegisterByInvitationForm.tsx: Formulario de registro por invitación
  • ForgotPasswordForm.tsx: Solicitud de recuperación
  • ResetPasswordForm.tsx: Establecer nueva contraseña
  • SwitchConstructoraModal.tsx: Modal para cambiar constructora activa

Estado (Zustand):

// apps/frontend/src/stores/authStore.ts
interface AuthStore {
  user: User | null;
  token: string | null;
  constructora: Constructora | null;
  constructoras: Constructora[];
  isAuthenticated: boolean;

  // Actions
  login: (email, password, constructoraId?) => Promise<void>;
  registerByInvitation: (token, data) => Promise<void>;
  logout: () => void;
  switchConstructora: (constructoraId) => Promise<void>;
  forgotPassword: (email) => Promise<void>;
  resetPassword: (token, newPassword) => Promise<void>;
  fetchMe: () => Promise<void>;
}

Rutas:

// apps/frontend/src/routes/auth.routes.tsx
const authRoutes = [
  { path: '/login', element: <LoginPage /> },
  { path: '/register/:invitationToken', element: <RegisterPage /> },
  { path: '/forgot-password', element: <ForgotPasswordPage /> },
  { path: '/reset-password/:token', element: <ResetPasswordPage /> },
];

Almacenamiento:

  • Token JWT guardado en localStorage (key: 'auth_token')
  • Constructora activa en localStorage (key: 'active_constructora')
  • Auto-login si existe token válido al cargar la app

Seguridad

Passwords:

  • Hasheadas con bcrypt (10 rounds)
  • Validación: min 8 caracteres, 1 número, 1 mayúscula (recomendado)
  • No se almacena ni loguea password en texto plano

JWT:

  • Firmado con secret key (desde .env: JWT_SECRET)
  • Expiración: 24 horas
  • Header: Authorization: Bearer {token}

Tokens de recuperación:

  • Generados con crypto.randomBytes(32)
  • Un solo uso (flag used)
  • Expiración: 1 hora
  • Invalidados después de uso

Invitaciones:

  • Token único por invitación
  • Expiración configurable (default: 7 días)
  • Solo 1 uso
  • Vinculado a email específico

Rate Limiting:

  • Login: 5 intentos por minuto por IP
  • Forgot password: 3 intentos por hora por IP
  • Register: 10 por hora por IP

📋 Dependencias

Antes:

  • Ninguna (primera historia del proyecto)
  • Infraestructura base migrada de GAMILIT (Sprint 0)

Después:

  • US-FUND-002 (Perfiles de usuario - requiere autenticación)
  • US-FUND-003 (Dashboard por rol - requiere autenticación)
  • US-FUND-005 (Sistema de sesiones - extiende esta funcionalidad)

📐 Definición de Hecho (DoD)

  • Código implementado y revisado (code review aprobado)
  • Tests unitarios para AuthService (>80% coverage)
  • Tests E2E para flujos de autenticación
  • Validación de seguridad (password hashing, JWT signing)
  • Documentación de API en Swagger/OpenAPI
  • Probado en ambiente de desarrollo
  • Sin warnings de seguridad en npm audit
  • Logs de auditoría configurados

🎨 Mockups/Wireframes

Flujo de Login

┌────────────────────────────────────────────┐
│         Login - Sistema de Obra            │
├────────────────────────────────────────────┤
│                                            │
│  Email:                                    │
│  [____________________________________]    │
│                                            │
│  Contraseña:                               │
│  [____________________________________]    │
│                                            │
│  ┌──────────────────────────────────────┐ │
│  │ [🏢] Constructora (opcional)         │ │
│  │  > ABC Constructora SA de CV         │ │
│  │    XYZ Edificaciones                 │ │
│  └──────────────────────────────────────┘ │
│                                            │
│  [ ] Recordarme                            │
│                                            │
│  [    Iniciar Sesión    ]                 │
│                                            │
│  ¿Olvidaste tu contraseña?                 │
│  ¿Tienes una invitación? Regístrate        │
│                                            │
└────────────────────────────────────────────┘

Flujo de Selector de Constructora (después de login)

┌────────────────────────────────────────────┐
│      Selecciona tu Constructora            │
├────────────────────────────────────────────┤
│                                            │
│  Tienes acceso a múltiples constructoras.  │
│  Selecciona con cuál deseas trabajar hoy:  │
│                                            │
│  ┌──────────────────────────────────────┐ │
│  │ 🏢 ABC Constructora SA de CV         │ │
│  │    Rol: Ingeniero                    │ │
│  │    5 obras activas                   │ │
│  │                     [Seleccionar] ───┼─┤
│  └──────────────────────────────────────┘ │
│                                            │
│  ┌──────────────────────────────────────┐ │
│  │ 🏢 XYZ Edificaciones                 │ │
│  │    Rol: Residente                    │ │
│  │    2 obras activas                   │ │
│  │                     [Seleccionar]    │ │
│  └──────────────────────────────────────┘ │
│                                            │
└────────────────────────────────────────────┘

🧪 Tareas de Implementación

Backend (Estimado: 16h, GAMILIT: 17h)

Total Backend: ~15h (~3.75 SP) - Ahorro 2h por reutilización

  • Tarea B.1: Migración de auth desde GAMILIT - Real: 2h

    • Copiar AuthService de GAMILIT
    • Copiar JwtStrategy
    • Adaptar DTOs para construcción
    • Configurar JwtModule
  • Tarea B.2: Multi-tenancy y constructoras - Estimado: 4h

    • Crear ConstructoraService
    • Endpoints de gestión de constructoras
    • Relación user ← user_constructoras → constructoras
    • Selector de constructora en login
  • Tarea B.3: Sistema de invitaciones - Estimado: 3h

    • Crear InvitationService
    • Endpoint POST /invitations/create (solo director)
    • Endpoint POST /auth/register-by-invitation
    • Envío de email con link de invitación
  • Tarea B.4: Recuperación de contraseña - Estimado: 2h

    • Migrar MailService de GAMILIT
    • POST /auth/forgot-password
    • POST /auth/reset-password
    • Templates de email
  • Tarea B.5: Endpoints adicionales - Estimado: 2h

    • POST /auth/switch-constructora
    • GET /auth/me (con constructora activa)
    • Logs de auditoría para cambios de constructora
  • Tarea B.6: Documentación Swagger - Estimado: 2h

    • Documentar todos los endpoints de auth
    • Ejemplos de request/response
    • Schemas de validación

Frontend (Estimado: 8h, GAMILIT: 9h)

Total Frontend: ~7h (~1.75 SP) - Ahorro 2h

  • Tarea F.1: Migración de componentes auth - Real: 2h

    • Copiar LoginForm de GAMILIT
    • Copiar RegisterForm
    • Copiar ForgotPasswordForm
    • Copiar ResetPasswordForm
  • Tarea F.2: Selector de constructora - Estimado: 3h

    • Componente ConstructoraSelector
    • Modal de selección post-login
    • Switcher en navbar (cambiar constructora activa)
    • Persistir selección en localStorage
  • Tarea F.3: Registro por invitación - Estimado: 2h

    • Página /register/:invitationToken
    • Validación de token
    • Formulario de registro con datos de invitación
  • Tarea F.4: AuthStore con Zustand - Real: 0h (migrado)

    • Copiar authStore de GAMILIT
    • Agregar: constructora, constructoras, switchConstructora
    • Hook useAuth

Testing (Estimado: 6h, GAMILIT: 5.5h)

Total Testing: ~6h (~1.5 SP) - Similar a GAMILIT

  • Tarea T.1: Tests unitarios backend - Estimado: 3h

    • Tests de AuthService (login, register, JWT)
    • Tests de ConstructoraService
    • Tests de InvitationService
    • Tests de guards (JwtAuthGuard, ConstructoraGuard)
  • Tarea T.2: Tests E2E - Estimado: 2h

    • Login con constructora única
    • Login con múltiples constructoras
    • Registro por invitación
    • Recuperación de contraseña
    • Cambio de constructora activa
  • Tarea T.3: Tests frontend - Estimado: 1h

    • Tests de componentes de formularios
    • Tests de AuthStore

Deployment (Estimado: 2h, GAMILIT: 2h)

Total Deployment: ~2h (~0.5 SP) - Similar

  • Tarea D.1: Variables de entorno - Estimado: 1h

    • JWT_SECRET configurado
    • SMTP configurado (envío de emails)
    • Frontend: API_URL configurado
  • Tarea D.2: Deploy y validación - Estimado: 1h

    • Deploy a staging
    • Smoke tests de autenticación
    • Validación de seguridad (bcrypt, JWT)

📊 Resumen de Horas

Categoría Estimado Real Varianza Ahorro vs GAMILIT
Backend 15h TBD - -2h (13%)
Frontend 7h TBD - -2h (22%)
Testing 6h TBD - +0.5h (0%)
Deployment 2h TBD - 0h (0%)
TOTAL 30h TBD - -3.5h (~12%)

Validación: 8 SP × 4h/SP = 32 horas estimadas (vs 30h optimizado)

Ahorro total: ~3.5 horas gracias a reutilización de GAMILIT


📅 Cronograma Real

Sprint: Sprint 1 (Semanas 2-3) Fecha Inicio: TBD Fecha Fin: TBD Estado: 🚧 Planificado

Notas:

  • Multi-tenancy es la diferencia principal vs GAMILIT
  • Selector de constructora requiere UX cuidadosa
  • Invitaciones reemplazan auto-registro abierto

🧪 Testing

Tests Unitarios (Backend)

describe('AuthService', () => {
  it('should hash password on register by invitation', async () => {
    const invitation = await createInvitation({ email: 'test@obra.com', role: 'resident' });
    const result = await authService.registerByInvitation(invitation.token, {
      password: 'SecurePass123',
      firstName: 'Juan',
      lastName: 'Pérez'
    });

    expect(result.user.password).not.toBe('SecurePass123');
    expect(await bcrypt.compare('SecurePass123', result.user.password)).toBe(true);
  });

  it('should generate JWT with constructora on login', async () => {
    const user = await createUser({ email: 'test@obra.com' });
    const constructora = await assignUserToConstructora(user.id, { role: 'engineer' });

    const result = await authService.login('test@obra.com', 'password', constructora.id);
    const decoded = jwt.verify(result.accessToken, process.env.JWT_SECRET);

    expect(decoded.constructoraId).toBe(constructora.id);
    expect(decoded.role).toBe('engineer');
  });

  it('should allow switching constructora', async () => {
    const user = await createUser();
    await assignUserToConstructora(user.id, { constructoraId: 'A', role: 'engineer' });
    await assignUserToConstructora(user.id, { constructoraId: 'B', role: 'resident' });

    const newToken = await authService.switchConstructora(user.id, 'B');
    const decoded = jwt.verify(newToken, process.env.JWT_SECRET);

    expect(decoded.constructoraId).toBe('B');
    expect(decoded.role).toBe('resident');
  });
});

Tests E2E

describe('Auth API E2E', () => {
  it('POST /auth/login - success with constructora selector', async () => {
    const user = await createUser({ email: 'multi@obra.com' });
    await assignUserToConstructora(user.id, { constructoraId: 'A' });
    await assignUserToConstructora(user.id, { constructoraId: 'B' });

    const response = await request(app)
      .post('/api/auth/login')
      .send({ email: 'multi@obra.com', password: 'password' });

    expect(response.status).toBe(200);
    expect(response.body.constructoras).toHaveLength(2);
    expect(response.body.accessToken).toBeDefined();
  });

  it('POST /auth/register-by-invitation - success', async () => {
    const invitation = await createInvitation({ email: 'new@obra.com', role: 'resident' });

    const response = await request(app)
      .post('/api/auth/register-by-invitation')
      .send({
        invitationToken: invitation.token,
        password: 'SecurePass123',
        firstName: 'Juan',
        lastName: 'Pérez'
      });

    expect(response.status).toBe(201);
    expect(response.body.user.email).toBe('new@obra.com');
    expect(response.body.accessToken).toBeDefined();
  });
});

🎯 Estimación

Desglose de Esfuerzo (8 SP = ~3-4 días):

  • Backend: multi-tenancy y invitaciones: 1.5 días
  • Frontend: selector de constructora: 1 día
  • Testing: 0.75 días
  • Ajustes y documentación: 0.75 días

Riesgos:

  • Selector de constructora puede requerir iteraciones de UX
  • Multi-tenancy en RLS requiere validaciones exhaustivas

Mitigaciones:

  • Mockups de selector antes de implementar
  • Tests E2E de multi-tenancy desde día 1

Creado: 2025-11-17 Actualizado: 2025-11-17 Responsable: Equipo Backend + Frontend Sprint: Sprint 1 (Semanas 2-3) Épica: MAI-001 - Fundamentos