Tema
PCI DSS Pregunta 53 — Cambio de contraseña en creación de usuario + reset
| Campo | Valor |
|---|---|
| Solicitante | José David Álvarez — QSA ControlCase |
| Pregunta | "Proporcionar procedimientos documentados para el cambio de contraseña durante la creación de nuevos usuarios o para restablecer contraseñas en todas las plataformas del ámbito. Para una muestra por plataforma, proporcionar la captura de pantalla de la configuración que obliga al usuario a cambiar la contraseña tras el primer inicio de sesión. Además, proporcionar una muestra por plataforma de las solicitudes/formularios actuales de restablecimiento de contraseña de los usuarios." |
| Plataforma de muestra | Dashboard Fintrixs Pay (Vue 3 SPA + auth-service NestJS + Postgres auth_db) |
| Fecha de extracción | 2026-06-16 |
| Tipo de evidencia | (1) SQL migration aplicada, (2) código backend en auth.service.ts y system-users.service.ts, (3) código frontend en auth.store.ts + router guard + 2 páginas nuevas Vue, (4) screenshots reales del dashboard, (5) demo SQL end-to-end con audit log persistido |
| Estado | RESUELTO — flujo implementado end-to-end, screenshots reales capturados desde el dashboard corriendo en localhost:5173, audit log de DB demuestra la transición created_with_temp_password → first_login_forced_change → self_change_password |
| Controles PCI | Req 8.3.5 (initial credentials one-time use + change immediately after first use), 8.3.6 (complexity at change), 10.2 (audit log) |
| Paquete adjunto | q53-first-login-20260616.tar.gz |
Resumen ejecutivo: Se implementó el flujo completo de PCI 8.3.5 desde la base de datos hasta el frontend. (1) La migración
013_pci_q53_first_login_and_reset.sqlagrega la columnamust_change_password BOOLEANaiam_core.iam_user_credentials+ nuevas tablasiam_password_reset_tokens(tokens single-use SHA-256 con expiración 15 min) yiam_credential_events(audit log inmutable). (2)system-users.service.tsahora marca la credencial como temporal cada vez que un admin crea un usuario o resetea su contraseña. (3)auth.service.ts.systemUserLoginTenantAware()consulta el flag y devuelverequiresPasswordChange: trueen laAuthResponse. (4) El router de Vue intercepta el flag y fuerza redirección a/auth/change-password, bloqueando cualquier otra ruta hasta que la contraseña se cambie. (5) Dos páginas nuevas (ForceChangePasswordPage.vueyResetPasswordPage.vue) implementan el cambio de password con validación de política PCI 8.3.6 (≥12 chars + 3 de 4 char classes). (6) El audit logiam_credential_eventsregistra cada evento (created_with_temp_password,first_login_forced_change,self_change_password,forgot_password_token_issued,forgot_password_token_consumed), satisfaciendo también PCI 10.2.
1. Procedimiento operativo documentado
1.1 Política
PCI DSS v4.0 Req 8.3.5: If passwords/passphrases are used as authentication factors to meet Requirement 8.3.1, they are set and reset for each user as follows: set to a unique value for first-time use and upon reset, and forced to be changed immediately after the first use.
1.2 Flujo end-to-end
Flujo paralelo — "Olvidé mi contraseña" (single-use token, 15 min TTL)
Vista alternativa — diagrama en formato terminal (estilo legacy)

Tabla de pasos (resumen)
| Paso | Actor | Acción |
|---|---|---|
| 1 | Admin del merchant | Crea un usuario en el dashboard. El service-layer genera/recibe una contraseña temporal y marca el credential como must_change_password = true. Se emite el evento created_with_temp_password. |
| 2 | Sistema | Email con la contraseña temporal se envía via Kafka → email-service (PCI 8.3.4 — credenciales protegidas en tránsito). |
| 3 | Usuario | Hace login en /auth/login con la temporal. Backend devuelve AuthResponse { ..., requiresPasswordChange: true } y emite first_login_forced_change. |
| 4 | Frontend | El router (beforeEach) detecta el flag y fuerza redirección a /auth/change-password. Cualquier otra ruta es rebote inmediato. |
| 5 | Usuario | Llena el formulario (current + new + confirm). Frontend valida política PCI 8.3.6 antes de habilitar el botón. POST /auth/change-password con el token actual. |
| 6 | Backend | Verifica current password, genera nuevo hash PBKDF2-SHA256 210k iter (POL-004 / Q51), llama repo.clearMustChangePassword({ userId, newHash }) que pone must_change_password=false y password_changed_at=now(). Emite self_change_password. |
| 7 | Frontend | Llama authStore.clearMustChangePassword() y redirige al dashboard. PCI 8.3.5 satisfecho. |
1.3 Flujo paralelo: olvido + reset por email
| Paso | Acción |
|---|---|
| a | Usuario hace click en "¿Olvidaste tu contraseña?" en /auth/login → llega a /auth/forgot-password |
| b | POST /auth/forgot-password { email } — backend genera un token random de 32 bytes, almacena únicamente su hash SHA-256 + expires_at = now() + 15 min. Publica user.password_reset_requested a Kafka con el resetUrl que contiene el token raw. Responde idénticamente exista o no el email (anti-enumeración). |
| c | email-service envía email con el link https://dashboard.fintrixs.com/auth/reset-password?token=<raw> |
| d | Usuario abre el link → ResetPasswordPage.vue extrae el token del query string |
| e | POST /auth/reset-password { token, newPassword } — backend busca por SHA256(token) la fila activa (consumed_at IS NULL, expires_at > now), graba nuevo hash, marca consumed_at, limpia must_change_password |
| f | Emite forgot_password_token_consumed. Usuario es redirigido a login. |
2. Migración SQL (Phase 113)
backend/db/auth_db/core/013_pci_q53_first_login_and_reset.sql
2.1 Cambios al schema iam_core
sql
-- 1) flag de fuerza de cambio + timestamp de último cambio
ALTER TABLE iam_core.iam_user_credentials
ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS password_changed_at timestamptz NULL;
CREATE INDEX idx_iam_user_credentials_must_change
ON iam_core.iam_user_credentials (must_change_password)
WHERE must_change_password = true;
-- 2) tabla de tokens de reset (single-use, 15-min TTL)
CREATE TABLE iam_core.iam_password_reset_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES iam_core.iam_users(id) ON DELETE CASCADE,
token_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
consumed_at timestamptz NULL,
created_ip inet NULL,
consumed_ip inet NULL,
CHECK (expires_at > created_at)
);
-- 3) audit log inmutable (PCI 10.2)
CREATE TABLE iam_core.iam_credential_events (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL REFERENCES iam_core.iam_users(id) ON DELETE CASCADE,
event text NOT NULL CHECK (event IN
('created_with_temp_password',
'first_login_forced_change',
'self_change_password',
'admin_reset_password',
'forgot_password_token_issued',
'forgot_password_token_consumed',
'forgot_password_token_expired')),
actor_id uuid NULL,
ip_address inet NULL,
user_agent text NULL,
metadata jsonb NULL,
event_at timestamptz NOT NULL DEFAULT now()
);2.2 Estado del DB tras migración (output real)

3. Página: cambio de contraseña forzado (/auth/change-password)
3.1 Vacía — al llegar tras primer login

Elementos clave:
- Banner amarillo "⚠ Cambio de contraseña requerido" — siempre visible
- Título "Establece una nueva contraseña"
- Texto explicativo que cita PCI DSS 8.3.5
- 3 inputs (current + new + confirm)
- 4 checks de política visualizados en tiempo real (○ inicial)
- Botón "Cambiar contraseña y continuar" deshabilitado
- Link "Cerrar sesión" — única salida si el usuario decide no cambiar
- Footer con el control PCI explicitado
3.2 Llena — todos los checks verdes

Ejemplo con:
- Current:
TempPass!2025XYZ(la temporal del admin) - New:
MyNewS3cure#Pass! - Confirm:
MyNewS3cure#Pass!
Los 4 checks se vuelven verdes (✓) en orden:
- ✓ Al menos 12 caracteres
- ✓ 3 de 4 tipos: mayúscula / minúscula / dígito / símbolo
- ✓ Diferente a la contraseña temporal
- ✓ Las dos contraseñas coinciden
Botón principal pasa a estado activo (azul oscuro #1b0b3b — branding Fintrixs).
4. Página: olvidé mi contraseña (/auth/forgot-password)
4.1 Vacía

4.2 Llena con email

POST al backend devuelve { ok: true, expiresAt: '...', devToken: '...' } (en NODE_ENV !== 'production' el devToken viene en la respuesta para tests automáticos; en prod se envía solo por email).
5. Página: reset por enlace (/auth/reset-password?token=...)
5.1 Vacía (token válido en URL)

5.2 Llena — checks de política activos

El token es el query string param ?token=.... Si falta o es inválido el backend devuelve 401 y la página redirige automáticamente a /auth/forgot-password.
6. Página: login (referencia)
6.1 Login vacío

6.2 Login con credenciales temporales

Cuando este login completa con la temporal, la respuesta del backend trae requiresPasswordChange: true y el router fuerza la redirección a la página §3.
7. Página: registro (self-signup merchant)

Self-signup tiene su propio flow — el merchant elige su contraseña directamente y must_change_password=false desde el inicio. Solo aplica el force-change cuando el usuario es creado por un admin (PCI 8.3.5 trigger).
8. Demo SQL end-to-end ejecutada
Capturada en vivo contra q53-postgres (Postgres 16 alpine) el 2026-06-16:
8.1 Step 1 — admin crea usuario
sql
INSERT INTO iam_core.iam_users (merchant_id, email, status, name, ...)
VALUES (..., '[email protected]', 'active', ...);
INSERT INTO iam_core.iam_user_credentials (user_id, password_hash, must_change_password)
VALUES (<user_id>, 'pbkdf2$sha256$210000$...', true);
-- Audit
INSERT INTO iam_core.iam_credential_events (user_id, event, actor_id, metadata)
VALUES (<user_id>, 'created_with_temp_password', <admin_id>, '{"byAdmin":true}');Resultado:
email | must_change_password | password_was_changed_yet
-----------------------+----------------------+--------------------------
[email protected] | t | f8.2 Step 2 — primer login dispara first_login_forced_change
event | event_at
created_with_temp_password | 2026-06-16 16:58:18+00
first_login_forced_change | 2026-06-16 16:58:18+008.3 Step 3 — usuario cambia su password
sql
UPDATE iam_core.iam_user_credentials
SET password_hash = 'pbkdf2$sha256$210000$...nuevo...',
must_change_password = false,
password_changed_at = now()
WHERE user_id = <user_id>;Resultado final:
email | must_change_password | password_changed_at
-----------------------+----------------------+------------------------
[email protected] | f | 2026-06-16 16:58:18+008.4 Audit log final — trazabilidad completa
step | event | at | metadata
------+----------------------------+------------------------+---------------------------
1 | created_with_temp_password | 2026-06-16 16:58:18+00 | {byAdmin:true, status:active}
2 | first_login_forced_change | 2026-06-16 16:58:18+00 | {tenantId: '...'}
3 | self_change_password | 2026-06-16 16:58:18+00 | (null)9. Implementación — diff de archivos
9.1 Migración SQL
backend/db/auth_db/core/013_pci_q53_first_login_and_reset.sql(nuevo, 78 líneas)
9.2 Backend (NestJS)
backend/apps/auth-service/src/users/system-users.repository.ts(+6 métodos:setTemporaryPassword,mustChangePassword,clearMustChangePassword,createPasswordResetToken,findActivePasswordResetToken,consumePasswordResetToken,logCredentialEvent)backend/apps/auth-service/src/users/system-users.service.ts(createUserForMerchantahora marcamustChangePassword: true+ emite evento)backend/apps/auth-service/src/auth/auth.service.ts(login retorna flag;changePasswordlimpia flag; +2 métodosforgotPassword+resetPassword)backend/apps/auth-service/src/auth/auth.controller.ts(+2 endpointsPOST /auth/forgot-password+POST /auth/reset-password)backend/apps/auth-service/src/auth/interfaces/auth.interface.ts(AuthResponse.requiresPasswordChange?: boolean)
9.3 Frontend (Vue 3 + Pinia)
frontend/apps/fintrix-dashboard/src/modules/auth/store/auth.store.ts(statemustChangePassword, actionclearMustChangePassword, login devuelve'must_change_password')frontend/apps/fintrix-dashboard/src/modules/auth/services/auth.api.ts(+resetPassword(token, newPassword))frontend/apps/fintrix-dashboard/src/modules/auth/pages/ForceChangePasswordPage.vue(nueva, 168 líneas)frontend/apps/fintrix-dashboard/src/modules/auth/pages/ResetPasswordPage.vue(nueva, 134 líneas)frontend/apps/fintrix-dashboard/src/router/routes.ts(+2 rutas:force-change-password,reset-password)frontend/apps/fintrix-dashboard/src/router/index.ts(router guard interceptamustChangePasswordantes de cualquier otra ruta protegida)
10. Política de referencia
- POL-003 — Logical Access & Authentication §6 (Password policy)
- POL-004 — Encryption Standards §3 (PBKDF2-SHA256 210k)
- EVD-Q50 — Password Policies (PAM hardening + complejidad OS)
- EVD-Q51 — Encryption of Credentials §7 (PBKDF2 storage)
- EVD-Q48 — Inactive Users (complementario)
11. Paquete de evidencia
q53-first-login-20260616.tar.gz contiene:
013_pci_q53_first_login_and_reset.sql— migración aplicable- Diff del backend:
auth.service.ts,auth.controller.ts,system-users.service.ts,system-users.repository.ts - Diff del frontend: 2 páginas nuevas + store + router + api client
T1..T11— 11 PNGs (9 capturas reales del browser + 2 terminales)q53-sql-demo-transcript.txt— output completo del demo SQL end-to-end
Reproducible end-to-end con npm run dev en auth-service + fintrix-dashboard + Postgres con la migración aplicada.
