19 KiB
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 constructoraConstructoraSelector.tsx: Selector de constructora (si usuario tiene múltiples)RegisterByInvitationForm.tsx: Formulario de registro por invitaciónForgotPasswordForm.tsx: Solicitud de recuperaciónResetPasswordForm.tsx: Establecer nueva contraseñaSwitchConstructoraModal.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