- Add 5 frontend specification documents (ET-*-frontend.md): - ET-AUTH-006: Authentication module frontend spec - ET-ML-008: ML Signals module frontend spec - ET-LLM-007: LLM Agent module frontend spec - ET-PFM-008: Portfolio Manager frontend spec (design) - ET-MKT-003: Marketplace frontend spec (design) - Add 8 new user stories: - US-AUTH-013: Global logout - US-AUTH-014: Device management - US-ML-008: Ensemble signal view - US-ML-009: ICT analysis view - US-ML-010: Multi-symbol scan - US-LLM-011: Execute trade from chat - US-PFM-013: Rebalance alerts - US-PFM-014: PDF report generation - Update task index with completed analysis Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1131 lines
25 KiB
Markdown
1131 lines
25 KiB
Markdown
---
|
|
id: "ET-AUTH-006"
|
|
title: "Frontend Specification for Auth Module"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-AUTH-003"
|
|
epic: "OQI-001"
|
|
version: "1.0"
|
|
created_date: "2026-01-25"
|
|
updated_date: "2026-01-25"
|
|
---
|
|
|
|
# ET-AUTH-006: Especificación Técnica - Frontend Auth
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2026-01-25
|
|
**Estado:** ✅ Implementado
|
|
**Épica:** [OQI-001](../_MAP.md)
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
Esta especificación detalla la implementación frontend del módulo de autenticación de Trading Platform, incluyendo páginas, componentes reutilizables, servicios de API y flujos de usuario.
|
|
|
|
---
|
|
|
|
## Stack Tecnológico
|
|
|
|
| Componente | Tecnología | Versión |
|
|
|------------|-----------|---------|
|
|
| Framework | React | 18.2.0 |
|
|
| Build Tool | Vite | 6.2.0 |
|
|
| Router | React Router | 6.x |
|
|
| State Management | Zustand | 4.4.7 |
|
|
| Data Fetching | TanStack Query | 5.14.0 |
|
|
| Styling | CSS Modules + Tailwind | 3.x |
|
|
| Forms | React Hook Form | 7.x |
|
|
| Validación | Zod | 3.x |
|
|
| HTTP Client | Axios | 1.x |
|
|
| Auth Tokens | JWT | - |
|
|
|
|
---
|
|
|
|
## Arquitectura Frontend
|
|
|
|
```
|
|
apps/frontend/
|
|
├── src/
|
|
│ ├── pages/
|
|
│ │ ├── auth/
|
|
│ │ │ ├── Login.tsx
|
|
│ │ │ ├── Register.tsx
|
|
│ │ │ ├── ForgotPassword.tsx
|
|
│ │ │ ├── ResetPassword.tsx
|
|
│ │ │ ├── VerifyEmail.tsx
|
|
│ │ │ └── AuthCallback.tsx
|
|
│ ├── components/
|
|
│ │ ├── auth/
|
|
│ │ │ ├── SocialLoginButtons.tsx
|
|
│ │ │ ├── PhoneLoginForm.tsx
|
|
│ │ │ ├── TwoFactorForm.tsx
|
|
│ │ │ └── PasswordStrengthMeter.tsx
|
|
│ ├── services/
|
|
│ │ ├── api/
|
|
│ │ │ └── authService.ts
|
|
│ ├── stores/
|
|
│ │ └── authStore.ts
|
|
│ ├── hooks/
|
|
│ │ ├── useAuth.ts
|
|
│ │ ├── useAuthForm.ts
|
|
│ │ └── useSessionManager.ts
|
|
│ ├── types/
|
|
│ │ └── auth.ts
|
|
│ └── utils/
|
|
│ ├── tokenManager.ts
|
|
│ └── validators.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Páginas de Autenticación
|
|
|
|
### 1. Login Page
|
|
|
|
**Ruta:** `/auth/login`
|
|
|
|
**Responsabilidades:**
|
|
- Autenticación con email/contraseña
|
|
- Redirección a OAuth (Google, GitHub, etc.)
|
|
- Recuperación de contraseña
|
|
- Manejo de 2FA si está activado
|
|
- Recordar usuario
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Usuario ingresa email y contraseña
|
|
2. Validar formato de datos
|
|
3. Llamar a /api/v1/auth/login
|
|
4. Si 2FA está activado:
|
|
→ Mostrar formulario de verificación 2FA
|
|
→ Usuario ingresa código
|
|
→ Llamar a /api/v1/auth/2fa/verify
|
|
5. Guardar tokens en localStorage/sessionStorage
|
|
6. Actualizar auth store (Zustand)
|
|
7. Redirigir a /dashboard
|
|
```
|
|
|
|
**Props/State:**
|
|
|
|
```typescript
|
|
interface LoginPageProps {
|
|
redirectTo?: string;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
interface LoginFormData {
|
|
email: string;
|
|
password: string;
|
|
rememberMe: boolean;
|
|
}
|
|
```
|
|
|
|
**Manejo de Errores:**
|
|
|
|
| Código | Error | Acción |
|
|
|--------|-------|--------|
|
|
| INVALID_CREDENTIALS | Credenciales inválidas | Mostrar mensaje de error |
|
|
| EMAIL_NOT_VERIFIED | Email no verificado | Botón para reenviar verificación |
|
|
| ACCOUNT_LOCKED | Cuenta bloqueada | Mostrar tiempo de espera |
|
|
| ACCOUNT_SUSPENDED | Cuenta suspendida | Mostrar mensaje de contacto |
|
|
| RATE_LIMIT | Demasiados intentos | Desactivar botón por tiempo |
|
|
|
|
---
|
|
|
|
### 2. Register Page
|
|
|
|
**Ruta:** `/auth/register`
|
|
|
|
**Responsabilidades:**
|
|
- Registro con email/contraseña
|
|
- Validación de contraseña fuerte
|
|
- Aceptación de términos y condiciones
|
|
- Registro social (opcional)
|
|
- Envío de email de verificación
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Usuario completa formulario:
|
|
- Email
|
|
- Contraseña
|
|
- Confirmar contraseña
|
|
- Nombre completo
|
|
- Aceptar términos
|
|
2. Validar localmente (Zod)
|
|
3. Mostrar indicador de fortaleza de contraseña
|
|
4. Llamar a /api/v1/auth/register
|
|
5. Mostrar confirmación de email enviado
|
|
6. Redirigir a /auth/verify-email después de 3 segundos
|
|
```
|
|
|
|
**Props/State:**
|
|
|
|
```typescript
|
|
interface RegisterFormData {
|
|
email: string;
|
|
password: string;
|
|
confirmPassword: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
acceptTerms: boolean;
|
|
acceptNewsletter?: boolean;
|
|
}
|
|
|
|
interface PasswordRequirements {
|
|
minLength: boolean;
|
|
hasUppercase: boolean;
|
|
hasLowercase: boolean;
|
|
hasNumber: boolean;
|
|
hasSpecialChar: boolean;
|
|
}
|
|
```
|
|
|
|
**Validaciones:**
|
|
|
|
- Email válido y único
|
|
- Contraseña: mínimo 12 caracteres, 1 mayúscula, 1 minúscula, 1 número, 1 carácter especial
|
|
- Contraseñas coinciden
|
|
- Edad mínima 18 años (si aplica)
|
|
- Términos aceptados
|
|
|
|
---
|
|
|
|
### 3. Forgot Password Page
|
|
|
|
**Ruta:** `/auth/forgot-password`
|
|
|
|
**Responsabilidades:**
|
|
- Solicitar recuperación de contraseña
|
|
- Validar email
|
|
- Enviar link de recuperación
|
|
- Mostrar confirmación
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Usuario ingresa email
|
|
2. Validar formato
|
|
3. Llamar a /api/v1/auth/forgot-password
|
|
4. Mostrar mensaje de confirmación
|
|
"Si la cuenta existe, recibirás instrucciones por email"
|
|
5. Mostrar campo para ingresar código alternativo
|
|
6. Opción de volver a login
|
|
```
|
|
|
|
**Seguridad:**
|
|
- No revelar si email existe
|
|
- Rate limiting en lado cliente
|
|
- Email enviado con validación de seguridad
|
|
|
|
---
|
|
|
|
### 4. Reset Password Page
|
|
|
|
**Ruta:** `/auth/reset-password?token=xxx`
|
|
|
|
**Responsabilidades:**
|
|
- Validar token de recuperación
|
|
- Permitir cambio de contraseña
|
|
- Verificar nueva contraseña
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Extraer token del URL
|
|
2. Validar que token es válido en cliente
|
|
3. Usuario ingresa nueva contraseña
|
|
4. Mostrar indicador de fortaleza
|
|
5. Validar confirmación
|
|
6. Llamar a /api/v1/auth/reset-password
|
|
7. Mostrar confirmación
|
|
8. Redirigir a /auth/login después de 3 segundos
|
|
```
|
|
|
|
**Manejo de Errores:**
|
|
|
|
| Error | Acción |
|
|
|-------|--------|
|
|
| Token inválido | Mostrar formulario para solicitar nuevo |
|
|
| Token expirado | Botón para reenviar |
|
|
|
|
---
|
|
|
|
### 5. Verify Email Page
|
|
|
|
**Ruta:** `/auth/verify-email`
|
|
|
|
**Responsabilidades:**
|
|
- Mostrar instrucciones de verificación
|
|
- Permitir ingreso manual de código
|
|
- Resenviar email de verificación
|
|
- Validar código de verificación
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Mostrar mensaje: "Revisa tu email para verificar tu cuenta"
|
|
2. Campo para ingresar código de 6 dígitos
|
|
3. Link para reenviar email (con rate limiting)
|
|
4. Contador de tiempo restante para expiración
|
|
5. Llamar a /api/v1/auth/verify-email con token
|
|
6. Mostrar confirmación
|
|
7. Botón "Continuar a login"
|
|
```
|
|
|
|
**Validaciones:**
|
|
- Código de 6 dígitos
|
|
- No expirado
|
|
- Formato correcto
|
|
|
|
---
|
|
|
|
### 6. Auth Callback Page
|
|
|
|
**Ruta:** `/auth/callback/:provider?code=xxx&state=xxx`
|
|
|
|
**Responsabilidades:**
|
|
- Procesar callback de OAuth
|
|
- Intercambiar código por tokens
|
|
- Manejar errores de OAuth
|
|
- Redirigir según resultado
|
|
|
|
**Flujo:**
|
|
|
|
```
|
|
1. Página muestra loading spinner
|
|
2. Extraer code y state del URL
|
|
3. Validar state para prevenir CSRF
|
|
4. Llamar a /api/v1/auth/oauth/{provider}
|
|
5. Si éxito:
|
|
→ Guardar tokens
|
|
→ Redirigir a redirectTo o /dashboard
|
|
6. Si error:
|
|
→ Mostrar mensaje de error
|
|
→ Botón para volver a intentar
|
|
→ Botón para ir a login
|
|
```
|
|
|
|
**Providers Soportados:**
|
|
- Google
|
|
- GitHub
|
|
- Apple (iOS)
|
|
- Microsoft
|
|
|
|
---
|
|
|
|
## Componentes Reutilizables
|
|
|
|
### SocialLoginButtons
|
|
|
|
**Ubicación:** `src/components/auth/SocialLoginButtons.tsx`
|
|
|
|
**Props:**
|
|
|
|
```typescript
|
|
interface SocialLoginButtonsProps {
|
|
onSuccess?: (user: AuthUser) => void;
|
|
onError?: (error: Error) => void;
|
|
className?: string;
|
|
showLabels?: boolean;
|
|
providers?: ('google' | 'github' | 'apple' | 'microsoft')[];
|
|
}
|
|
```
|
|
|
|
**Funcionalidades:**
|
|
|
|
```
|
|
1. Botones para cada proveedor OAuth
|
|
2. Iconos proporcionados por providers
|
|
3. Hover y estados activos
|
|
4. Loading state durante autenticación
|
|
5. Manejo de errores con mensajes claros
|
|
6. Responsivo en mobile
|
|
```
|
|
|
|
**Rendering:**
|
|
|
|
```jsx
|
|
<SocialLoginButtons
|
|
providers={['google', 'github']}
|
|
showLabels={true}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
```
|
|
|
|
**Estilos:**
|
|
- Google: Azul (#4285F4)
|
|
- GitHub: Negro (#333)
|
|
- Apple: Negro (#000)
|
|
- Microsoft: Azul (#00A4EF)
|
|
- Ancho: 100% en mobile, auto en desktop
|
|
- Altura: 48px
|
|
- Spacing entre botones: 12px
|
|
|
|
---
|
|
|
|
### PhoneLoginForm
|
|
|
|
**Ubicación:** `src/components/auth/PhoneLoginForm.tsx`
|
|
|
|
**Props:**
|
|
|
|
```typescript
|
|
interface PhoneLoginFormProps {
|
|
onSuccess?: (user: AuthUser, tokens: TokenResponse) => void;
|
|
onError?: (error: Error) => void;
|
|
defaultCountry?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface PhoneLoginFormState {
|
|
step: 'phone' | 'otp';
|
|
phone: string;
|
|
code: string;
|
|
channel: 'sms' | 'whatsapp';
|
|
expiresAt: Date;
|
|
loading: boolean;
|
|
error?: string;
|
|
}
|
|
```
|
|
|
|
**Flujo de Pasos:**
|
|
|
|
**Paso 1: Ingreso de Teléfono**
|
|
|
|
```
|
|
1. Selector de país con bandera
|
|
2. Campo de teléfono internacional
|
|
3. Selector de canal (SMS/WhatsApp)
|
|
4. Validar formato con libphonenumber-js
|
|
5. Botón "Enviar código"
|
|
```
|
|
|
|
**Paso 2: Verificación OTP**
|
|
|
|
```
|
|
1. Mostrar último 4 dígitos del teléfono
|
|
2. Campo de 6 dígitos (auto-focus después de cada)
|
|
3. Countdown timer (5 minutos)
|
|
4. Link "Reenviar código" (después de 30s)
|
|
5. Link "Cambiar número"
|
|
6. Botón "Verificar código"
|
|
```
|
|
|
|
**Validaciones:**
|
|
|
|
```typescript
|
|
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
const otpRegex = /^\d{6}$/;
|
|
```
|
|
|
|
---
|
|
|
|
### TwoFactorForm
|
|
|
|
**Ubicación:** `src/components/auth/TwoFactorForm.tsx`
|
|
|
|
**Props:**
|
|
|
|
```typescript
|
|
interface TwoFactorFormProps {
|
|
tempToken: string;
|
|
methods: ('totp' | 'backup_code' | 'sms')[];
|
|
onSuccess: (tokens: TokenResponse) => void;
|
|
onError: (error: Error) => void;
|
|
}
|
|
|
|
interface TwoFactorFormState {
|
|
method: 'totp' | 'backup_code' | 'sms';
|
|
code: string;
|
|
remainingAttempts: number;
|
|
loading: boolean;
|
|
}
|
|
```
|
|
|
|
**Métodos Soportados:**
|
|
|
|
1. **TOTP (Authenticator)**
|
|
- Campo de 6 dígitos
|
|
- Auto-focus y validación en tiempo real
|
|
- Contador de tiempo (30s por código)
|
|
|
|
2. **Backup Codes**
|
|
- Campo de 8 caracteres (sin guiones)
|
|
- Validación de formato
|
|
- Mensaje de "código usado"
|
|
|
|
3. **SMS**
|
|
- Reenviador de OTP a teléfono
|
|
- Campo de 6 dígitos
|
|
- Countdown timer
|
|
|
|
---
|
|
|
|
### PasswordStrengthMeter
|
|
|
|
**Ubicación:** `src/components/auth/PasswordStrengthMeter.tsx`
|
|
|
|
**Props:**
|
|
|
|
```typescript
|
|
interface PasswordStrengthMeterProps {
|
|
password: string;
|
|
showRequirements?: boolean;
|
|
minLength?: number;
|
|
className?: string;
|
|
}
|
|
|
|
interface PasswordRequirements {
|
|
minLength: boolean; // >= 12 caracteres
|
|
hasUppercase: boolean; // >= 1 mayúscula
|
|
hasLowercase: boolean; // >= 1 minúscula
|
|
hasNumber: boolean; // >= 1 número
|
|
hasSpecialChar: boolean; // >= 1 carácter especial
|
|
}
|
|
```
|
|
|
|
**Rendering:**
|
|
|
|
```
|
|
Barra de fortaleza:
|
|
┌──────────────────────┐
|
|
│ █████░░░░░░░░░░░░░░│ Muy débil
|
|
└──────────────────────┘
|
|
|
|
Requisitos:
|
|
✓ Mínimo 12 caracteres
|
|
✗ Una mayúscula
|
|
✓ Una minúscula
|
|
✓ Un número
|
|
✗ Carácter especial
|
|
```
|
|
|
|
**Colores:**
|
|
- Rojo: Muy débil
|
|
- Naranja: Débil
|
|
- Amarillo: Medio
|
|
- Verde claro: Fuerte
|
|
- Verde oscuro: Muy fuerte
|
|
|
|
---
|
|
|
|
## Servicios de API
|
|
|
|
### authService.ts
|
|
|
|
**Ubicación:** `src/services/api/authService.ts`
|
|
|
|
**Métodos:**
|
|
|
|
```typescript
|
|
class AuthService {
|
|
// Login
|
|
login(email: string, password: string): Promise<LoginResponse>
|
|
|
|
// Registro
|
|
register(data: RegisterRequest): Promise<RegisterResponse>
|
|
|
|
// Logout
|
|
logout(): Promise<void>
|
|
|
|
// Renovar tokens
|
|
refreshTokens(refreshToken: string): Promise<TokenResponse>
|
|
|
|
// Obtener usuario actual
|
|
getCurrentUser(): Promise<UserData>
|
|
|
|
// OAuth
|
|
getOAuthUrl(provider: string, redirectTo?: string): Promise<{ authUrl: string; state: string }>
|
|
oauth(provider: string, code: string, state: string): Promise<LoginResponse>
|
|
unlinkOAuth(provider: string): Promise<void>
|
|
|
|
// Teléfono
|
|
sendPhoneOTP(phone: string, channel: 'sms' | 'whatsapp'): Promise<{ expiresAt: Date }>
|
|
verifyPhoneOTP(phone: string, code: string): Promise<LoginResponse>
|
|
|
|
// 2FA
|
|
setupTwoFactor(): Promise<{ secret: string; qrCode: string; otpauthUrl: string }>
|
|
enableTwoFactor(code: string): Promise<{ backupCodes: string[] }>
|
|
verifyTwoFactor(tempToken: string, code: string): Promise<LoginResponse>
|
|
disableTwoFactor(code: string): Promise<void>
|
|
|
|
// Contraseña
|
|
forgotPassword(email: string): Promise<void>
|
|
resetPassword(token: string, password: string): Promise<void>
|
|
|
|
// Email
|
|
verifyEmail(token: string): Promise<void>
|
|
resendVerificationEmail(email: string): Promise<void>
|
|
|
|
// Sesiones
|
|
getSessions(): Promise<Session[]>
|
|
revokeSession(sessionId: string): Promise<void>
|
|
revokeAllOtherSessions(): Promise<void>
|
|
}
|
|
|
|
export const authService = new AuthService();
|
|
```
|
|
|
|
**Configuración Base:**
|
|
|
|
```typescript
|
|
const apiClient = axios.create({
|
|
baseURL: process.env.VITE_API_URL || 'http://localhost:3080/api/v1',
|
|
timeout: 10000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Interceptor para agregar token
|
|
apiClient.interceptors.request.use((config) => {
|
|
const token = tokenManager.getAccessToken();
|
|
if (token) {
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Interceptor para renovar token expirado
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
if (error.response?.status === 401) {
|
|
const refreshToken = tokenManager.getRefreshToken();
|
|
if (refreshToken) {
|
|
// Intentar renovar
|
|
const response = await refreshTokens(refreshToken);
|
|
// Reintentar solicitud original
|
|
} else {
|
|
// Redirigir a login
|
|
}
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Estado Global (Zustand)
|
|
|
|
### authStore.ts
|
|
|
|
**Ubicación:** `src/stores/authStore.ts`
|
|
|
|
```typescript
|
|
interface AuthState {
|
|
// Estado
|
|
user: AuthUser | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
tokens: TokenResponse | null;
|
|
|
|
// Métodos
|
|
setUser: (user: AuthUser | null) => void;
|
|
setTokens: (tokens: TokenResponse | null) => void;
|
|
login: (email: string, password: string) => Promise<void>;
|
|
register: (data: RegisterRequest) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
refreshUser: () => Promise<void>;
|
|
clearError: () => void;
|
|
|
|
// 2FA
|
|
setRequires2FA: (requires: boolean) => void;
|
|
setTempToken: (token: string) => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
tokens: null,
|
|
|
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
|
|
|
setTokens: (tokens) => {
|
|
set({ tokens });
|
|
if (tokens) {
|
|
tokenManager.setTokens(tokens);
|
|
} else {
|
|
tokenManager.clear();
|
|
}
|
|
},
|
|
|
|
login: async (email, password) => {
|
|
// Implementación
|
|
},
|
|
|
|
logout: async () => {
|
|
set({ user: null, tokens: null, isAuthenticated: false });
|
|
tokenManager.clear();
|
|
},
|
|
|
|
// ... otros métodos
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## Hooks Personalizados
|
|
|
|
### useAuth()
|
|
|
|
```typescript
|
|
function useAuth() {
|
|
const store = useAuthStore();
|
|
|
|
return {
|
|
user: store.user,
|
|
isAuthenticated: store.isAuthenticated,
|
|
isLoading: store.isLoading,
|
|
login: store.login,
|
|
logout: store.logout,
|
|
register: store.register,
|
|
};
|
|
}
|
|
|
|
// Uso:
|
|
function MyComponent() {
|
|
const { user, isAuthenticated, logout } = useAuth();
|
|
|
|
if (!isAuthenticated) return null;
|
|
|
|
return <div>Bienvenido {user?.firstName}</div>;
|
|
}
|
|
```
|
|
|
|
### useAuthForm()
|
|
|
|
```typescript
|
|
function useAuthForm(formType: 'login' | 'register' | 'resetPassword') {
|
|
const form = useForm<LoginFormData | RegisterFormData>({
|
|
resolver: zodResolver(getSchema(formType)),
|
|
mode: 'onBlur',
|
|
});
|
|
|
|
const onSubmit = async (data) => {
|
|
// Validar y enviar
|
|
};
|
|
|
|
return {
|
|
form,
|
|
isSubmitting: form.formState.isSubmitting,
|
|
errors: form.formState.errors,
|
|
onSubmit: form.handleSubmit(onSubmit),
|
|
};
|
|
}
|
|
```
|
|
|
|
### useSessionManager()
|
|
|
|
```typescript
|
|
function useSessionManager() {
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const loadSessions = async () => {
|
|
setLoading(true);
|
|
const data = await authService.getSessions();
|
|
setSessions(data);
|
|
setLoading(false);
|
|
};
|
|
|
|
const revokeSession = async (id: string) => {
|
|
await authService.revokeSession(id);
|
|
await loadSessions();
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadSessions();
|
|
}, []);
|
|
|
|
return { sessions, loading, revokeSession };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Flujos de Usuario
|
|
|
|
### Flujo: Registrarse con Email
|
|
|
|
```
|
|
1. Usuario accede a /auth/register
|
|
2. Completa formulario:
|
|
- Email
|
|
- Contraseña (con validación en tiempo real)
|
|
- Nombre
|
|
- Acepta términos
|
|
3. Validación local (Zod)
|
|
4. POST /api/v1/auth/register
|
|
5. Respuesta: Usuario con status "pending_verification"
|
|
6. Redirigir a /auth/verify-email?email=xxx
|
|
7. Usuario ingresa código del email
|
|
8. POST /api/v1/auth/verify-email
|
|
9. Email verificado
|
|
10. Redirigir a /auth/login con mensaje "Cuenta verificada"
|
|
```
|
|
|
|
### Flujo: Iniciar Sesión con Email/Contraseña
|
|
|
|
```
|
|
1. Usuario accede a /auth/login
|
|
2. Ingresa email y contraseña
|
|
3. POST /api/v1/auth/login
|
|
4. Respuesta de servidor:
|
|
a) Sin 2FA: tokens (accessToken, refreshToken)
|
|
b) Con 2FA: tempToken y métodos disponibles
|
|
5. Si sin 2FA:
|
|
- Guardar tokens en store
|
|
- Redirigir a /dashboard
|
|
6. Si con 2FA:
|
|
- Mostrar formulario de verificación
|
|
- Usuario ingresa código
|
|
- POST /api/v1/auth/2fa/verify
|
|
- Guardar tokens
|
|
- Redirigir a /dashboard
|
|
```
|
|
|
|
### Flujo: Iniciar Sesión con OAuth (Google)
|
|
|
|
```
|
|
1. Usuario haz clic en "Iniciar sesión con Google"
|
|
2. GET /api/v1/auth/oauth/google/url?redirectTo=/dashboard
|
|
3. Respuesta: authUrl + state
|
|
4. Redirigir a Google
|
|
5. Usuario autentica y otorga permisos
|
|
6. Google redirige a /auth/callback/google?code=xxx&state=xxx
|
|
7. POST /api/v1/auth/oauth/google { code, state }
|
|
8. Respuesta: tokens
|
|
9. Guardar en store
|
|
10. Redirigir a /dashboard
|
|
```
|
|
|
|
### Flujo: Cambiar Contraseña Olvidada
|
|
|
|
```
|
|
1. Usuario accede a /auth/forgot-password
|
|
2. Ingresa email
|
|
3. POST /api/v1/auth/forgot-password
|
|
4. Mostrar mensaje: "Si la cuenta existe..."
|
|
5. Usuario recibe email con link
|
|
6. Link dirigido a /auth/reset-password?token=xxx
|
|
7. Usuario ingresa nueva contraseña
|
|
8. POST /api/v1/auth/reset-password { token, password }
|
|
9. Mostrar confirmación
|
|
10. Redirigir a /auth/login
|
|
```
|
|
|
|
### Flujo: Activar 2FA (TOTP)
|
|
|
|
```
|
|
1. Usuario va a Settings > Seguridad
|
|
2. Haz clic en "Activar autenticación de dos factores"
|
|
3. POST /api/v1/auth/2fa/setup
|
|
4. Respuesta: QR code + secret
|
|
5. Usuario escanea con Google Authenticator/Authy
|
|
6. Usuario ingresa código de 6 dígitos
|
|
7. POST /api/v1/auth/2fa/enable { code }
|
|
8. Respuesta: Backup codes
|
|
9. Usuario descargar/copia backup codes
|
|
10. Mostrar confirmación: "2FA activado"
|
|
```
|
|
|
|
### Flujo: Iniciar Sesión con Teléfono
|
|
|
|
```
|
|
1. Usuario accede a /auth/phone-login
|
|
2. Selecciona país y ingresa teléfono
|
|
3. Elige canal: SMS o WhatsApp
|
|
4. POST /api/v1/auth/phone/send { phone, channel }
|
|
5. Mostrar "Código enviado a +52 555 1234..."
|
|
6. Usuario ingresa código de 6 dígitos
|
|
7. POST /api/v1/auth/phone/verify { phone, code }
|
|
8. Si usuario nuevo: Crear account automáticamente
|
|
9. Respuesta: tokens
|
|
10. Redirigir a /dashboard
|
|
```
|
|
|
|
---
|
|
|
|
## Seguridad
|
|
|
|
### Protección CSRF
|
|
|
|
```typescript
|
|
// Usar state token en OAuth
|
|
const state = crypto.randomUUID();
|
|
sessionStorage.setItem('oauth_state', state);
|
|
|
|
// Validar en callback
|
|
const urlState = new URLSearchParams(location.search).get('state');
|
|
if (urlState !== sessionStorage.getItem('oauth_state')) {
|
|
throw new Error('Invalid state token');
|
|
}
|
|
```
|
|
|
|
### Manejo de Tokens
|
|
|
|
```typescript
|
|
class TokenManager {
|
|
setTokens(tokens: TokenResponse) {
|
|
sessionStorage.setItem('accessToken', tokens.accessToken);
|
|
localStorage.setItem('refreshToken', tokens.refreshToken);
|
|
}
|
|
|
|
getAccessToken(): string | null {
|
|
return sessionStorage.getItem('accessToken');
|
|
}
|
|
|
|
getRefreshToken(): string | null {
|
|
return localStorage.getItem('refreshToken');
|
|
}
|
|
|
|
clear() {
|
|
sessionStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
}
|
|
|
|
isTokenExpired(token: string): boolean {
|
|
const decoded = jwtDecode(token);
|
|
return decoded.exp * 1000 < Date.now();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Protección XSS
|
|
|
|
- Usar React (que escapa automáticamente)
|
|
- Sanitizar HTML si es necesario: `DOMPurify`
|
|
- Content Security Policy headers
|
|
|
|
### Validación de Inputs
|
|
|
|
```typescript
|
|
const schemas = {
|
|
login: z.object({
|
|
email: z.string().email('Email inválido'),
|
|
password: z.string().min(1, 'Requerido'),
|
|
}),
|
|
register: z.object({
|
|
email: z.string().email('Email inválido'),
|
|
password: z.string()
|
|
.min(12, 'Mínimo 12 caracteres')
|
|
.regex(/[A-Z]/, 'Debe contener mayúscula')
|
|
.regex(/[a-z]/, 'Debe contener minúscula')
|
|
.regex(/[0-9]/, 'Debe contener número')
|
|
.regex(/[^a-zA-Z0-9]/, 'Debe contener carácter especial'),
|
|
confirmPassword: z.string(),
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: 'Las contraseñas no coinciden',
|
|
path: ['confirmPassword'],
|
|
}),
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Rutas Protegidas
|
|
|
|
```typescript
|
|
function ProtectedRoute({ children }: { children: ReactNode }) {
|
|
const { isAuthenticated, isLoading } = useAuth();
|
|
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (!isAuthenticated) return <Navigate to="/auth/login" />;
|
|
|
|
return children;
|
|
}
|
|
|
|
// Uso:
|
|
<Routes>
|
|
<Route path="/auth/login" element={<LoginPage />} />
|
|
<Route path="/dashboard" element={
|
|
<ProtectedRoute>
|
|
<DashboardPage />
|
|
</ProtectedRoute>
|
|
} />
|
|
</Routes>
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
```typescript
|
|
describe('authService.login', () => {
|
|
it('debe retornar tokens al login exitoso', async () => {
|
|
const result = await authService.login('user@example.com', 'password');
|
|
expect(result.data.tokens).toBeDefined();
|
|
expect(result.data.user).toBeDefined();
|
|
});
|
|
|
|
it('debe lanzar error con credenciales inválidas', async () => {
|
|
await expect(
|
|
authService.login('user@example.com', 'wrong')
|
|
).rejects.toThrow('INVALID_CREDENTIALS');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```typescript
|
|
describe('Login Flow', () => {
|
|
it('debe completar flujo de login y redirigir', async () => {
|
|
render(<LoginPage />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
|
|
await userEvent.type(screen.getByLabelText('Password'), 'SecurePass123!');
|
|
await userEvent.click(screen.getByText('Iniciar sesión'));
|
|
|
|
await waitFor(() => {
|
|
expect(window.location.pathname).toBe('/dashboard');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Tipos TypeScript
|
|
|
|
**Ubicación:** `src/types/auth.ts`
|
|
|
|
```typescript
|
|
export interface AuthUser {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone?: string;
|
|
role: 'investor' | 'admin' | 'support';
|
|
status: 'active' | 'pending_verification' | 'suspended';
|
|
emailVerified: boolean;
|
|
phoneVerified: boolean;
|
|
twoFactorEnabled: boolean;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
profile?: {
|
|
displayName: string;
|
|
avatarUrl?: string;
|
|
preferredLanguage: string;
|
|
timezone: string;
|
|
};
|
|
oauthProviders: string[];
|
|
}
|
|
|
|
export interface TokenResponse {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresIn: number;
|
|
tokenType: 'Bearer';
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
success: boolean;
|
|
data: {
|
|
user: AuthUser;
|
|
tokens?: TokenResponse;
|
|
requires2FA?: boolean;
|
|
tempToken?: string;
|
|
methods?: string[];
|
|
};
|
|
}
|
|
|
|
export interface Session {
|
|
id: string;
|
|
device: {
|
|
type: 'desktop' | 'mobile' | 'tablet';
|
|
browser: string;
|
|
browserVersion?: string;
|
|
os: string;
|
|
osVersion: string;
|
|
};
|
|
location: {
|
|
city: string;
|
|
country: string;
|
|
countryCode: string;
|
|
};
|
|
ipAddress: string;
|
|
lastActivity: Date;
|
|
createdAt: Date;
|
|
isCurrent: boolean;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist de Implementación
|
|
|
|
- [ ] Páginas de autenticación creadas
|
|
- [ ] Componentes reutilizables implementados
|
|
- [ ] Servicios de API configurados
|
|
- [ ] Estado Zustand inicializado
|
|
- [ ] Hooks personalizados desarrollados
|
|
- [ ] Rutas protegidas implementadas
|
|
- [ ] Validaciones con Zod aplicadas
|
|
- [ ] Manejo de errores completado
|
|
- [ ] Tests unitarios escritos
|
|
- [ ] Tests de integración completados
|
|
- [ ] Security review realizado
|
|
- [ ] Documentación de componentes lista
|
|
- [ ] CHANGELOG actualizado
|
|
|
|
---
|
|
|
|
## Performance
|
|
|
|
### Code Splitting
|
|
|
|
```typescript
|
|
const LoginPage = lazy(() => import('./pages/auth/LoginPage'));
|
|
const RegisterPage = lazy(() => import('./pages/auth/RegisterPage'));
|
|
|
|
<Suspense fallback={<LoadingSpinner />}>
|
|
<Routes>
|
|
<Route path="/auth/login" element={<LoginPage />} />
|
|
</Routes>
|
|
</Suspense>
|
|
```
|
|
|
|
### Memoización
|
|
|
|
```typescript
|
|
const SocialLoginButtons = memo(function SocialLoginButtons(props) {
|
|
// Componente
|
|
});
|
|
```
|
|
|
|
### Request Caching
|
|
|
|
```typescript
|
|
const { data: user } = useQuery({
|
|
queryKey: ['auth', 'me'],
|
|
queryFn: () => authService.getCurrentUser(),
|
|
staleTime: 5 * 60 * 1000, // 5 minutos
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [ET-AUTH-001: OAuth Specification](./ET-AUTH-001-oauth.md)
|
|
- [ET-AUTH-002: JWT Specification](./ET-AUTH-002-jwt.md)
|
|
- [ET-AUTH-004: API Endpoints](./ET-AUTH-004-api.md)
|
|
- [ET-AUTH-005: Security](./ET-AUTH-005-security.md)
|
|
- [React Documentation](https://react.dev)
|
|
- [React Router Documentation](https://reactrouter.com)
|
|
- [Zustand Documentation](https://github.com/pmndrs/zustand)
|
|
- [React Hook Form Documentation](https://react-hook-form.com)
|
|
- [Zod Validation Library](https://zod.dev)
|