@nauth-toolkit/core 0.1.14 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/database-columns.d.ts +70 -0
- package/dist/adapters/database-columns.d.ts.map +1 -1
- package/dist/adapters/database-columns.js +76 -2
- package/dist/adapters/database-columns.js.map +1 -1
- package/dist/adapters/express.adapter.d.ts +66 -0
- package/dist/adapters/express.adapter.d.ts.map +1 -1
- package/dist/adapters/express.adapter.js +80 -0
- package/dist/adapters/express.adapter.js.map +1 -1
- package/dist/adapters/fastify.adapter.d.ts +42 -0
- package/dist/adapters/fastify.adapter.d.ts.map +1 -1
- package/dist/adapters/fastify.adapter.js +86 -0
- package/dist/adapters/fastify.adapter.js.map +1 -1
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +9 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/storage.factory.d.ts +107 -0
- package/dist/adapters/storage.factory.d.ts.map +1 -1
- package/dist/adapters/storage.factory.js +114 -0
- package/dist/adapters/storage.factory.js.map +1 -1
- package/dist/adapters.d.ts +8 -0
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +8 -0
- package/dist/adapters.js.map +1 -1
- package/dist/bootstrap.d.ts +82 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +106 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/dto/admin-set-password.dto.d.ts +90 -0
- package/dist/dto/admin-set-password.dto.d.ts.map +1 -1
- package/dist/dto/admin-set-password.dto.js +91 -0
- package/dist/dto/admin-set-password.dto.js.map +1 -1
- package/dist/dto/auth-challenge.dto.d.ts +170 -0
- package/dist/dto/auth-challenge.dto.d.ts.map +1 -1
- package/dist/dto/auth-challenge.dto.js +170 -0
- package/dist/dto/auth-challenge.dto.js.map +1 -1
- package/dist/dto/auth-response.dto.d.ts +196 -0
- package/dist/dto/auth-response.dto.d.ts.map +1 -1
- package/dist/dto/auth-response.dto.js +149 -0
- package/dist/dto/auth-response.dto.js.map +1 -1
- package/dist/dto/challenge-response.dto.d.ts +155 -0
- package/dist/dto/challenge-response.dto.d.ts.map +1 -1
- package/dist/dto/challenge-response.dto.js +8 -0
- package/dist/dto/challenge-response.dto.js.map +1 -1
- package/dist/dto/change-password-request.dto.d.ts +35 -0
- package/dist/dto/change-password-request.dto.d.ts.map +1 -1
- package/dist/dto/change-password-request.dto.js +35 -0
- package/dist/dto/change-password-request.dto.js.map +1 -1
- package/dist/dto/change-password-response.dto.d.ts +25 -0
- package/dist/dto/change-password-response.dto.d.ts.map +1 -1
- package/dist/dto/change-password-response.dto.js +25 -0
- package/dist/dto/change-password-response.dto.js.map +1 -1
- package/dist/dto/change-password.dto.d.ts +45 -0
- package/dist/dto/change-password.dto.d.ts.map +1 -1
- package/dist/dto/change-password.dto.js +45 -0
- package/dist/dto/change-password.dto.js.map +1 -1
- package/dist/dto/confirm-forgot-password.dto.d.ts +59 -0
- package/dist/dto/confirm-forgot-password.dto.d.ts.map +1 -1
- package/dist/dto/confirm-forgot-password.dto.js +59 -0
- package/dist/dto/confirm-forgot-password.dto.js.map +1 -1
- package/dist/dto/error-response.dto.d.ts +103 -0
- package/dist/dto/error-response.dto.d.ts.map +1 -1
- package/dist/dto/error-response.dto.js +103 -0
- package/dist/dto/error-response.dto.js.map +1 -1
- package/dist/dto/forgot-password.dto.d.ts +58 -0
- package/dist/dto/forgot-password.dto.d.ts.map +1 -1
- package/dist/dto/forgot-password.dto.js +58 -0
- package/dist/dto/forgot-password.dto.js.map +1 -1
- package/dist/dto/get-available-methods.dto.d.ts +37 -0
- package/dist/dto/get-available-methods.dto.d.ts.map +1 -1
- package/dist/dto/get-available-methods.dto.js +37 -0
- package/dist/dto/get-available-methods.dto.js.map +1 -1
- package/dist/dto/get-challenge-data-response.dto.d.ts +24 -0
- package/dist/dto/get-challenge-data-response.dto.d.ts.map +1 -1
- package/dist/dto/get-challenge-data-response.dto.js +24 -0
- package/dist/dto/get-challenge-data-response.dto.js.map +1 -1
- package/dist/dto/get-challenge-data.dto.d.ts +46 -0
- package/dist/dto/get-challenge-data.dto.d.ts.map +1 -1
- package/dist/dto/get-challenge-data.dto.js +46 -0
- package/dist/dto/get-challenge-data.dto.js.map +1 -1
- package/dist/dto/get-client-info.dto.d.ts +74 -0
- package/dist/dto/get-client-info.dto.d.ts.map +1 -1
- package/dist/dto/get-client-info.dto.js +74 -0
- package/dist/dto/get-client-info.dto.js.map +1 -1
- package/dist/dto/get-device-token-response.dto.d.ts +21 -0
- package/dist/dto/get-device-token-response.dto.d.ts.map +1 -1
- package/dist/dto/get-device-token-response.dto.js +21 -0
- package/dist/dto/get-device-token-response.dto.js.map +1 -1
- package/dist/dto/get-events-by-type.dto.d.ts +50 -0
- package/dist/dto/get-events-by-type.dto.d.ts.map +1 -1
- package/dist/dto/get-events-by-type.dto.js +50 -0
- package/dist/dto/get-events-by-type.dto.js.map +1 -1
- package/dist/dto/get-ip-address-response.dto.d.ts +20 -0
- package/dist/dto/get-ip-address-response.dto.d.ts.map +1 -1
- package/dist/dto/get-ip-address-response.dto.js +20 -0
- package/dist/dto/get-ip-address-response.dto.js.map +1 -1
- package/dist/dto/get-mfa-status.dto.d.ts +59 -0
- package/dist/dto/get-mfa-status.dto.d.ts.map +1 -1
- package/dist/dto/get-mfa-status.dto.js +59 -0
- package/dist/dto/get-mfa-status.dto.js.map +1 -1
- package/dist/dto/get-risk-assessment-history.dto.d.ts +28 -0
- package/dist/dto/get-risk-assessment-history.dto.d.ts.map +1 -1
- package/dist/dto/get-risk-assessment-history.dto.js +28 -0
- package/dist/dto/get-risk-assessment-history.dto.js.map +1 -1
- package/dist/dto/get-session-id-response.dto.d.ts +21 -0
- package/dist/dto/get-session-id-response.dto.d.ts.map +1 -1
- package/dist/dto/get-session-id-response.dto.js +21 -0
- package/dist/dto/get-session-id-response.dto.js.map +1 -1
- package/dist/dto/get-setup-data-response.dto.d.ts +27 -0
- package/dist/dto/get-setup-data-response.dto.d.ts.map +1 -1
- package/dist/dto/get-setup-data-response.dto.js +27 -0
- package/dist/dto/get-setup-data-response.dto.js.map +1 -1
- package/dist/dto/get-setup-data.dto.d.ts +51 -0
- package/dist/dto/get-setup-data.dto.d.ts.map +1 -1
- package/dist/dto/get-setup-data.dto.js +51 -0
- package/dist/dto/get-setup-data.dto.js.map +1 -1
- package/dist/dto/get-suspicious-activity.dto.d.ts +31 -0
- package/dist/dto/get-suspicious-activity.dto.d.ts.map +1 -1
- package/dist/dto/get-suspicious-activity.dto.js +31 -0
- package/dist/dto/get-suspicious-activity.dto.js.map +1 -1
- package/dist/dto/get-user-agent-response.dto.d.ts +19 -0
- package/dist/dto/get-user-agent-response.dto.d.ts.map +1 -1
- package/dist/dto/get-user-agent-response.dto.js +19 -0
- package/dist/dto/get-user-agent-response.dto.js.map +1 -1
- package/dist/dto/get-user-auth-history.dto.d.ts +64 -0
- package/dist/dto/get-user-auth-history.dto.d.ts.map +1 -1
- package/dist/dto/get-user-auth-history.dto.js +64 -0
- package/dist/dto/get-user-auth-history.dto.js.map +1 -1
- package/dist/dto/get-user-by-email.dto.d.ts +42 -0
- package/dist/dto/get-user-by-email.dto.d.ts.map +1 -1
- package/dist/dto/get-user-by-email.dto.js +42 -0
- package/dist/dto/get-user-by-email.dto.js.map +1 -1
- package/dist/dto/get-user-by-id.dto.d.ts +32 -0
- package/dist/dto/get-user-by-id.dto.d.ts.map +1 -1
- package/dist/dto/get-user-by-id.dto.js +32 -0
- package/dist/dto/get-user-by-id.dto.js.map +1 -1
- package/dist/dto/get-user-devices.dto.d.ts +34 -0
- package/dist/dto/get-user-devices.dto.d.ts.map +1 -1
- package/dist/dto/get-user-devices.dto.js +34 -0
- package/dist/dto/get-user-devices.dto.js.map +1 -1
- package/dist/dto/get-user-response.dto.d.ts +14 -0
- package/dist/dto/get-user-response.dto.d.ts.map +1 -1
- package/dist/dto/get-user-response.dto.js +15 -0
- package/dist/dto/get-user-response.dto.js.map +1 -1
- package/dist/dto/has-provider.dto.d.ts +33 -0
- package/dist/dto/has-provider.dto.d.ts.map +1 -1
- package/dist/dto/has-provider.dto.js +33 -0
- package/dist/dto/has-provider.dto.js.map +1 -1
- package/dist/dto/index.js +5 -0
- package/dist/dto/index.js.map +1 -1
- package/dist/dto/is-trusted-device-response.dto.d.ts +28 -0
- package/dist/dto/is-trusted-device-response.dto.d.ts.map +1 -1
- package/dist/dto/is-trusted-device-response.dto.js +28 -0
- package/dist/dto/is-trusted-device-response.dto.js.map +1 -1
- package/dist/dto/list-providers-response.dto.d.ts +19 -0
- package/dist/dto/list-providers-response.dto.d.ts.map +1 -1
- package/dist/dto/list-providers-response.dto.js +19 -0
- package/dist/dto/list-providers-response.dto.js.map +1 -1
- package/dist/dto/login.dto.d.ts +48 -0
- package/dist/dto/login.dto.d.ts.map +1 -1
- package/dist/dto/login.dto.js +50 -1
- package/dist/dto/login.dto.js.map +1 -1
- package/dist/dto/logout-all-response.dto.d.ts +20 -0
- package/dist/dto/logout-all-response.dto.d.ts.map +1 -1
- package/dist/dto/logout-all-response.dto.js +20 -0
- package/dist/dto/logout-all-response.dto.js.map +1 -1
- package/dist/dto/logout-all.dto.d.ts +42 -0
- package/dist/dto/logout-all.dto.d.ts.map +1 -1
- package/dist/dto/logout-all.dto.js +42 -0
- package/dist/dto/logout-all.dto.js.map +1 -1
- package/dist/dto/logout-response.dto.d.ts +21 -0
- package/dist/dto/logout-response.dto.d.ts.map +1 -1
- package/dist/dto/logout-response.dto.js +21 -0
- package/dist/dto/logout-response.dto.js.map +1 -1
- package/dist/dto/logout.dto.d.ts +45 -0
- package/dist/dto/logout.dto.d.ts.map +1 -1
- package/dist/dto/logout.dto.js +45 -0
- package/dist/dto/logout.dto.js.map +1 -1
- package/dist/dto/refresh-token.dto.d.ts +28 -0
- package/dist/dto/refresh-token.dto.d.ts.map +1 -1
- package/dist/dto/refresh-token.dto.js +28 -0
- package/dist/dto/refresh-token.dto.js.map +1 -1
- package/dist/dto/remove-devices.dto.d.ts +51 -0
- package/dist/dto/remove-devices.dto.d.ts.map +1 -1
- package/dist/dto/remove-devices.dto.js +51 -0
- package/dist/dto/remove-devices.dto.js.map +1 -1
- package/dist/dto/resend-code-response.dto.d.ts +28 -0
- package/dist/dto/resend-code-response.dto.d.ts.map +1 -1
- package/dist/dto/resend-code-response.dto.js +28 -0
- package/dist/dto/resend-code-response.dto.js.map +1 -1
- package/dist/dto/resend-code.dto.d.ts +37 -0
- package/dist/dto/resend-code.dto.d.ts.map +1 -1
- package/dist/dto/resend-code.dto.js +37 -0
- package/dist/dto/resend-code.dto.js.map +1 -1
- package/dist/dto/reset-password.dto.d.ts +74 -0
- package/dist/dto/reset-password.dto.d.ts.map +1 -1
- package/dist/dto/reset-password.dto.js +76 -1
- package/dist/dto/reset-password.dto.js.map +1 -1
- package/dist/dto/respond-challenge.dto.d.ts +147 -0
- package/dist/dto/respond-challenge.dto.d.ts.map +1 -1
- package/dist/dto/respond-challenge.dto.js +162 -0
- package/dist/dto/respond-challenge.dto.js.map +1 -1
- package/dist/dto/set-mfa-exemption.dto.d.ts +65 -0
- package/dist/dto/set-mfa-exemption.dto.d.ts.map +1 -1
- package/dist/dto/set-mfa-exemption.dto.js +65 -0
- package/dist/dto/set-mfa-exemption.dto.js.map +1 -1
- package/dist/dto/set-must-change-password-response.dto.d.ts +23 -0
- package/dist/dto/set-must-change-password-response.dto.d.ts.map +1 -1
- package/dist/dto/set-must-change-password-response.dto.js +23 -0
- package/dist/dto/set-must-change-password-response.dto.js.map +1 -1
- package/dist/dto/set-must-change-password.dto.d.ts +32 -0
- package/dist/dto/set-must-change-password.dto.d.ts.map +1 -1
- package/dist/dto/set-must-change-password.dto.js +32 -0
- package/dist/dto/set-must-change-password.dto.js.map +1 -1
- package/dist/dto/set-preferred-method.dto.d.ts +48 -0
- package/dist/dto/set-preferred-method.dto.d.ts.map +1 -1
- package/dist/dto/set-preferred-method.dto.js +48 -0
- package/dist/dto/set-preferred-method.dto.js.map +1 -1
- package/dist/dto/setup-mfa.dto.d.ts +62 -0
- package/dist/dto/setup-mfa.dto.d.ts.map +1 -1
- package/dist/dto/setup-mfa.dto.js +62 -0
- package/dist/dto/setup-mfa.dto.js.map +1 -1
- package/dist/dto/signup.dto.d.ts +92 -0
- package/dist/dto/signup.dto.d.ts.map +1 -1
- package/dist/dto/signup.dto.js +93 -0
- package/dist/dto/signup.dto.js.map +1 -1
- package/dist/dto/social-auth.dto.d.ts +234 -0
- package/dist/dto/social-auth.dto.d.ts.map +1 -1
- package/dist/dto/social-auth.dto.js +234 -0
- package/dist/dto/social-auth.dto.js.map +1 -1
- package/dist/dto/trust-device-response.dto.d.ts +26 -0
- package/dist/dto/trust-device-response.dto.d.ts.map +1 -1
- package/dist/dto/trust-device-response.dto.js +26 -0
- package/dist/dto/trust-device-response.dto.js.map +1 -1
- package/dist/dto/trust-device.dto.d.ts +9 -0
- package/dist/dto/trust-device.dto.d.ts.map +1 -1
- package/dist/dto/trust-device.dto.js +9 -0
- package/dist/dto/trust-device.dto.js.map +1 -1
- package/dist/dto/update-user-attributes-request.dto.d.ts +36 -0
- package/dist/dto/update-user-attributes-request.dto.d.ts.map +1 -1
- package/dist/dto/update-user-attributes-request.dto.js +36 -0
- package/dist/dto/update-user-attributes-request.dto.js.map +1 -1
- package/dist/dto/user-response.dto.d.ts +81 -0
- package/dist/dto/user-response.dto.d.ts.map +1 -1
- package/dist/dto/user-response.dto.js +84 -2
- package/dist/dto/user-response.dto.js.map +1 -1
- package/dist/dto/user-update.dto.d.ts +132 -0
- package/dist/dto/user-update.dto.d.ts.map +1 -1
- package/dist/dto/user-update.dto.js +133 -0
- package/dist/dto/user-update.dto.js.map +1 -1
- package/dist/dto/verify-email.dto.d.ts +171 -0
- package/dist/dto/verify-email.dto.d.ts.map +1 -1
- package/dist/dto/verify-email.dto.js +173 -1
- package/dist/dto/verify-email.dto.js.map +1 -1
- package/dist/dto/verify-mfa-code.dto.d.ts +65 -0
- package/dist/dto/verify-mfa-code.dto.d.ts.map +1 -1
- package/dist/dto/verify-mfa-code.dto.js +65 -0
- package/dist/dto/verify-mfa-code.dto.js.map +1 -1
- package/dist/dto/verify-phone-by-sub.dto.d.ts +49 -0
- package/dist/dto/verify-phone-by-sub.dto.d.ts.map +1 -1
- package/dist/dto/verify-phone-by-sub.dto.js +49 -0
- package/dist/dto/verify-phone-by-sub.dto.js.map +1 -1
- package/dist/dto/verify-phone.dto.d.ts +139 -0
- package/dist/dto/verify-phone.dto.d.ts.map +1 -1
- package/dist/dto/verify-phone.dto.js +142 -1
- package/dist/dto/verify-phone.dto.js.map +1 -1
- package/dist/dto.d.ts +10 -0
- package/dist/dto.d.ts.map +1 -1
- package/dist/dto.js +10 -0
- package/dist/dto.js.map +1 -1
- package/dist/entities/auth-audit.entity.d.ts +159 -0
- package/dist/entities/auth-audit.entity.d.ts.map +1 -1
- package/dist/entities/auth-audit.entity.js +166 -0
- package/dist/entities/auth-audit.entity.js.map +1 -1
- package/dist/entities/challenge-session.entity.d.ts +87 -0
- package/dist/entities/challenge-session.entity.d.ts.map +1 -1
- package/dist/entities/challenge-session.entity.js +87 -0
- package/dist/entities/challenge-session.entity.js.map +1 -1
- package/dist/entities/index.d.ts +18 -0
- package/dist/entities/index.d.ts.map +1 -1
- package/dist/entities/index.js +18 -0
- package/dist/entities/index.js.map +1 -1
- package/dist/entities/login-attempt.entity.d.ts +43 -0
- package/dist/entities/login-attempt.entity.d.ts.map +1 -1
- package/dist/entities/login-attempt.entity.js +43 -0
- package/dist/entities/login-attempt.entity.js.map +1 -1
- package/dist/entities/mfa-device.entity.d.ts +112 -0
- package/dist/entities/mfa-device.entity.d.ts.map +1 -1
- package/dist/entities/mfa-device.entity.js +112 -0
- package/dist/entities/mfa-device.entity.js.map +1 -1
- package/dist/entities/rate-limit.entity.d.ts +31 -0
- package/dist/entities/rate-limit.entity.d.ts.map +1 -1
- package/dist/entities/rate-limit.entity.js +31 -0
- package/dist/entities/rate-limit.entity.js.map +1 -1
- package/dist/entities/session.entity.d.ts +121 -0
- package/dist/entities/session.entity.d.ts.map +1 -1
- package/dist/entities/session.entity.js +121 -0
- package/dist/entities/session.entity.js.map +1 -1
- package/dist/entities/social-account.entity.d.ts +75 -0
- package/dist/entities/social-account.entity.d.ts.map +1 -1
- package/dist/entities/social-account.entity.js +75 -0
- package/dist/entities/social-account.entity.js.map +1 -1
- package/dist/entities/storage-lock.entity.d.ts +28 -0
- package/dist/entities/storage-lock.entity.d.ts.map +1 -1
- package/dist/entities/storage-lock.entity.js +28 -0
- package/dist/entities/storage-lock.entity.js.map +1 -1
- package/dist/entities/trusted-device.entity.d.ts +83 -0
- package/dist/entities/trusted-device.entity.d.ts.map +1 -1
- package/dist/entities/trusted-device.entity.js +83 -0
- package/dist/entities/trusted-device.entity.js.map +1 -1
- package/dist/entities/user.entity.d.ts +166 -0
- package/dist/entities/user.entity.d.ts.map +1 -1
- package/dist/entities/user.entity.js +166 -0
- package/dist/entities/user.entity.js.map +1 -1
- package/dist/entities/verification-token.entity.d.ts +102 -0
- package/dist/entities/verification-token.entity.d.ts.map +1 -1
- package/dist/entities/verification-token.entity.js +102 -0
- package/dist/entities/verification-token.entity.js.map +1 -1
- package/dist/entities.d.ts +8 -0
- package/dist/entities.d.ts.map +1 -1
- package/dist/entities.js +8 -0
- package/dist/entities.js.map +1 -1
- package/dist/enums/auth-audit-event-type.enum.d.ts +211 -0
- package/dist/enums/auth-audit-event-type.enum.d.ts.map +1 -1
- package/dist/enums/auth-audit-event-type.enum.js +244 -0
- package/dist/enums/auth-audit-event-type.enum.js.map +1 -1
- package/dist/enums/error-codes.enum.d.ts +296 -0
- package/dist/enums/error-codes.enum.d.ts.map +1 -1
- package/dist/enums/error-codes.enum.js +332 -0
- package/dist/enums/error-codes.enum.js.map +1 -1
- package/dist/enums/mfa-method.enum.d.ts +74 -0
- package/dist/enums/mfa-method.enum.d.ts.map +1 -1
- package/dist/enums/mfa-method.enum.js +64 -0
- package/dist/enums/mfa-method.enum.js.map +1 -1
- package/dist/enums/risk-factor.enum.d.ts +91 -0
- package/dist/enums/risk-factor.enum.d.ts.map +1 -1
- package/dist/enums/risk-factor.enum.js +97 -0
- package/dist/enums/risk-factor.enum.js.map +1 -1
- package/dist/exceptions/nauth.exception.d.ts +149 -0
- package/dist/exceptions/nauth.exception.d.ts.map +1 -1
- package/dist/exceptions/nauth.exception.js +159 -0
- package/dist/exceptions/nauth.exception.js.map +1 -1
- package/dist/handlers/auth.handler.d.ts +32 -0
- package/dist/handlers/auth.handler.d.ts.map +1 -1
- package/dist/handlers/auth.handler.js +47 -1
- package/dist/handlers/auth.handler.js.map +1 -1
- package/dist/handlers/client-info.handler.d.ts +25 -0
- package/dist/handlers/client-info.handler.d.ts.map +1 -1
- package/dist/handlers/client-info.handler.js +36 -2
- package/dist/handlers/client-info.handler.js.map +1 -1
- package/dist/handlers/csrf.handler.d.ts +32 -0
- package/dist/handlers/csrf.handler.d.ts.map +1 -1
- package/dist/handlers/csrf.handler.js +49 -1
- package/dist/handlers/csrf.handler.js.map +1 -1
- package/dist/handlers/token-delivery.handler.d.ts +16 -0
- package/dist/handlers/token-delivery.handler.d.ts.map +1 -1
- package/dist/handlers/token-delivery.handler.js +22 -1
- package/dist/handlers/token-delivery.handler.js.map +1 -1
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/client-info.interface.d.ts +58 -0
- package/dist/interfaces/client-info.interface.d.ts.map +1 -1
- package/dist/interfaces/config.interface.d.ts +1774 -0
- package/dist/interfaces/config.interface.d.ts.map +1 -1
- package/dist/interfaces/config.interface.js +16 -0
- package/dist/interfaces/config.interface.js.map +1 -1
- package/dist/interfaces/entities.interface.d.ts +48 -0
- package/dist/interfaces/entities.interface.d.ts.map +1 -1
- package/dist/interfaces/entities.interface.js +8 -0
- package/dist/interfaces/entities.interface.js.map +1 -1
- package/dist/interfaces/index.js +5 -0
- package/dist/interfaces/index.js.map +1 -1
- package/dist/interfaces/logger.interface.d.ts +213 -0
- package/dist/interfaces/logger.interface.d.ts.map +1 -1
- package/dist/interfaces/logger.interface.js +35 -0
- package/dist/interfaces/logger.interface.js.map +1 -1
- package/dist/interfaces/mfa-provider.interface.d.ts +134 -0
- package/dist/interfaces/mfa-provider.interface.d.ts.map +1 -1
- package/dist/interfaces/oauth.interface.d.ts +110 -0
- package/dist/interfaces/oauth.interface.d.ts.map +1 -1
- package/dist/interfaces/provider.interface.d.ts +83 -0
- package/dist/interfaces/provider.interface.d.ts.map +1 -1
- package/dist/interfaces/sms-template.interface.d.ts +246 -0
- package/dist/interfaces/sms-template.interface.d.ts.map +1 -1
- package/dist/interfaces/sms-template.interface.js +26 -0
- package/dist/interfaces/sms-template.interface.js.map +1 -1
- package/dist/interfaces/social-auth-provider.interface.d.ts +115 -0
- package/dist/interfaces/social-auth-provider.interface.d.ts.map +1 -1
- package/dist/interfaces/storage-adapter.interface.d.ts +37 -0
- package/dist/interfaces/storage-adapter.interface.d.ts.map +1 -1
- package/dist/interfaces/template.interface.d.ts +351 -0
- package/dist/interfaces/template.interface.d.ts.map +1 -1
- package/dist/interfaces/template.interface.js +13 -0
- package/dist/interfaces/template.interface.js.map +1 -1
- package/dist/interfaces/token-verifier.interface.d.ts +101 -0
- package/dist/interfaces/token-verifier.interface.d.ts.map +1 -1
- package/dist/interfaces.d.ts +8 -0
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js +8 -0
- package/dist/interfaces.js.map +1 -1
- package/dist/internal.d.ts +120 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +138 -0
- package/dist/internal.js.map +1 -1
- package/dist/platform/interfaces.d.ts +187 -0
- package/dist/platform/interfaces.d.ts.map +1 -1
- package/dist/platform/interfaces.js +11 -0
- package/dist/platform/interfaces.js.map +1 -1
- package/dist/schemas/auth-config.schema.d.ts +48 -0
- package/dist/schemas/auth-config.schema.d.ts.map +1 -1
- package/dist/schemas/auth-config.schema.js +188 -9
- package/dist/schemas/auth-config.schema.js.map +1 -1
- package/dist/services/adaptive-mfa-decision.service.d.ts +144 -0
- package/dist/services/adaptive-mfa-decision.service.d.ts.map +1 -1
- package/dist/services/adaptive-mfa-decision.service.js +151 -5
- package/dist/services/adaptive-mfa-decision.service.js.map +1 -1
- package/dist/services/auth-audit.service.d.ts +195 -0
- package/dist/services/auth-audit.service.d.ts.map +1 -1
- package/dist/services/auth-audit.service.js +228 -1
- package/dist/services/auth-audit.service.js.map +1 -1
- package/dist/services/auth-challenge-helper.service.d.ts +144 -1
- package/dist/services/auth-challenge-helper.service.d.ts.map +1 -1
- package/dist/services/auth-challenge-helper.service.js +295 -16
- package/dist/services/auth-challenge-helper.service.js.map +1 -1
- package/dist/services/auth-flow-context-builder.service.d.ts +120 -1
- package/dist/services/auth-flow-context-builder.service.d.ts.map +1 -1
- package/dist/services/auth-flow-context-builder.service.js +184 -5
- package/dist/services/auth-flow-context-builder.service.js.map +1 -1
- package/dist/services/auth-flow-rules.d.ts +136 -0
- package/dist/services/auth-flow-rules.d.ts.map +1 -1
- package/dist/services/auth-flow-rules.js +137 -0
- package/dist/services/auth-flow-rules.js.map +1 -1
- package/dist/services/auth-flow-state-definitions.d.ts +40 -0
- package/dist/services/auth-flow-state-definitions.d.ts.map +1 -1
- package/dist/services/auth-flow-state-definitions.js +98 -0
- package/dist/services/auth-flow-state-definitions.js.map +1 -1
- package/dist/services/auth-flow-state-machine.service.d.ts +91 -0
- package/dist/services/auth-flow-state-machine.service.d.ts.map +1 -1
- package/dist/services/auth-flow-state-machine.service.js +102 -0
- package/dist/services/auth-flow-state-machine.service.js.map +1 -1
- package/dist/services/auth-flow-state-machine.types.d.ts +221 -0
- package/dist/services/auth-flow-state-machine.types.d.ts.map +1 -1
- package/dist/services/auth-flow-state-machine.types.js +47 -0
- package/dist/services/auth-flow-state-machine.types.js.map +1 -1
- package/dist/services/auth.service.d.ts +397 -1
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +943 -27
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/challenge.service.d.ts +255 -1
- package/dist/services/challenge.service.d.ts.map +1 -1
- package/dist/services/challenge.service.js +327 -3
- package/dist/services/challenge.service.js.map +1 -1
- package/dist/services/client-info.service.d.ts +143 -0
- package/dist/services/client-info.service.d.ts.map +1 -1
- package/dist/services/client-info.service.js +161 -0
- package/dist/services/client-info.service.js.map +1 -1
- package/dist/services/csrf.service.d.ts +15 -0
- package/dist/services/csrf.service.d.ts.map +1 -1
- package/dist/services/csrf.service.js +16 -0
- package/dist/services/csrf.service.js.map +1 -1
- package/dist/services/email-verification.service.d.ts +52 -0
- package/dist/services/email-verification.service.d.ts.map +1 -1
- package/dist/services/email-verification.service.js +149 -10
- package/dist/services/email-verification.service.js.map +1 -1
- package/dist/services/geo-location.service.d.ts +105 -0
- package/dist/services/geo-location.service.d.ts.map +1 -1
- package/dist/services/geo-location.service.js +188 -2
- package/dist/services/geo-location.service.js.map +1 -1
- package/dist/services/jwt.service.d.ts +257 -0
- package/dist/services/jwt.service.d.ts.map +1 -1
- package/dist/services/jwt.service.js +284 -1
- package/dist/services/jwt.service.js.map +1 -1
- package/dist/services/mfa-base.service.d.ts +179 -1
- package/dist/services/mfa-base.service.d.ts.map +1 -1
- package/dist/services/mfa-base.service.js +256 -2
- package/dist/services/mfa-base.service.js.map +1 -1
- package/dist/services/mfa.service.d.ts +304 -0
- package/dist/services/mfa.service.d.ts.map +1 -1
- package/dist/services/mfa.service.js +380 -0
- package/dist/services/mfa.service.js.map +1 -1
- package/dist/services/password-reset.service.d.ts +46 -0
- package/dist/services/password-reset.service.d.ts.map +1 -1
- package/dist/services/password-reset.service.js +79 -0
- package/dist/services/password-reset.service.js.map +1 -1
- package/dist/services/password.service.d.ts +139 -0
- package/dist/services/password.service.d.ts.map +1 -1
- package/dist/services/password.service.js +167 -9
- package/dist/services/password.service.js.map +1 -1
- package/dist/services/phone-verification.service.d.ts +75 -0
- package/dist/services/phone-verification.service.d.ts.map +1 -1
- package/dist/services/phone-verification.service.js +188 -6
- package/dist/services/phone-verification.service.js.map +1 -1
- package/dist/services/risk-detection.service.d.ts +198 -0
- package/dist/services/risk-detection.service.d.ts.map +1 -1
- package/dist/services/risk-detection.service.js +358 -11
- package/dist/services/risk-detection.service.js.map +1 -1
- package/dist/services/risk-scoring.service.d.ts +84 -0
- package/dist/services/risk-scoring.service.d.ts.map +1 -1
- package/dist/services/risk-scoring.service.js +87 -0
- package/dist/services/risk-scoring.service.js.map +1 -1
- package/dist/services/session.service.d.ts +204 -0
- package/dist/services/session.service.d.ts.map +1 -1
- package/dist/services/session.service.js +289 -4
- package/dist/services/session.service.js.map +1 -1
- package/dist/services/social-auth-base.service.d.ts +123 -1
- package/dist/services/social-auth-base.service.d.ts.map +1 -1
- package/dist/services/social-auth-base.service.js +155 -2
- package/dist/services/social-auth-base.service.js.map +1 -1
- package/dist/services/social-auth.service.d.ts +191 -0
- package/dist/services/social-auth.service.d.ts.map +1 -1
- package/dist/services/social-auth.service.js +215 -2
- package/dist/services/social-auth.service.js.map +1 -1
- package/dist/services/social-provider-registry.service.d.ts +86 -0
- package/dist/services/social-provider-registry.service.d.ts.map +1 -1
- package/dist/services/social-provider-registry.service.js +86 -0
- package/dist/services/social-provider-registry.service.js.map +1 -1
- package/dist/services/trusted-device.service.d.ts +105 -0
- package/dist/services/trusted-device.service.d.ts.map +1 -1
- package/dist/services/trusted-device.service.js +133 -4
- package/dist/services/trusted-device.service.js.map +1 -1
- package/dist/storage/account-lockout-storage.service.d.ts +35 -0
- package/dist/storage/account-lockout-storage.service.d.ts.map +1 -1
- package/dist/storage/account-lockout-storage.service.js +35 -0
- package/dist/storage/account-lockout-storage.service.js.map +1 -1
- package/dist/storage/memory-storage.adapter.d.ts +148 -0
- package/dist/storage/memory-storage.adapter.d.ts.map +1 -1
- package/dist/storage/memory-storage.adapter.js +201 -6
- package/dist/storage/memory-storage.adapter.js.map +1 -1
- package/dist/storage/rate-limit-storage.service.d.ts +3 -0
- package/dist/storage/rate-limit-storage.service.d.ts.map +1 -1
- package/dist/storage/rate-limit-storage.service.js +4 -0
- package/dist/storage/rate-limit-storage.service.js.map +1 -1
- package/dist/storage.d.ts +8 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +8 -0
- package/dist/storage.js.map +1 -1
- package/dist/templates/html-template.engine.d.ts +110 -0
- package/dist/templates/html-template.engine.d.ts.map +1 -1
- package/dist/templates/html-template.engine.js +147 -0
- package/dist/templates/html-template.engine.js.map +1 -1
- package/dist/templates/index.d.ts +5 -0
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +5 -0
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/sms-template.engine.d.ts +151 -0
- package/dist/templates/sms-template.engine.d.ts.map +1 -1
- package/dist/templates/sms-template.engine.js +171 -0
- package/dist/templates/sms-template.engine.js.map +1 -1
- package/dist/templates.d.ts +8 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +8 -0
- package/dist/templates.js.map +1 -1
- package/dist/utils/common-passwords.d.ts +42 -0
- package/dist/utils/common-passwords.d.ts.map +1 -1
- package/dist/utils/common-passwords.js +88 -0
- package/dist/utils/common-passwords.js.map +1 -1
- package/dist/utils/context-storage.d.ts +129 -0
- package/dist/utils/context-storage.d.ts.map +1 -1
- package/dist/utils/context-storage.js +129 -0
- package/dist/utils/context-storage.js.map +1 -1
- package/dist/utils/cookie-names.util.d.ts +35 -0
- package/dist/utils/cookie-names.util.d.ts.map +1 -1
- package/dist/utils/cookie-names.util.js +37 -0
- package/dist/utils/cookie-names.util.js.map +1 -1
- package/dist/utils/cookies.util.d.ts +19 -0
- package/dist/utils/cookies.util.d.ts.map +1 -1
- package/dist/utils/cookies.util.js +30 -3
- package/dist/utils/cookies.util.js.map +1 -1
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/ip-extractor.d.ts +88 -0
- package/dist/utils/ip-extractor.d.ts.map +1 -1
- package/dist/utils/ip-extractor.js +109 -16
- package/dist/utils/ip-extractor.js.map +1 -1
- package/dist/utils/nauth-logger.d.ts +70 -0
- package/dist/utils/nauth-logger.d.ts.map +1 -1
- package/dist/utils/nauth-logger.js +82 -4
- package/dist/utils/nauth-logger.js.map +1 -1
- package/dist/utils/pii-redactor.d.ts +70 -0
- package/dist/utils/pii-redactor.d.ts.map +1 -1
- package/dist/utils/pii-redactor.js +102 -0
- package/dist/utils/pii-redactor.js.map +1 -1
- package/dist/utils/setup/get-repositories.d.ts +16 -0
- package/dist/utils/setup/get-repositories.d.ts.map +1 -1
- package/dist/utils/setup/get-repositories.js +21 -0
- package/dist/utils/setup/get-repositories.js.map +1 -1
- package/dist/utils/setup/init-services.d.ts +40 -1
- package/dist/utils/setup/init-services.d.ts.map +1 -1
- package/dist/utils/setup/init-services.js +98 -0
- package/dist/utils/setup/init-services.js.map +1 -1
- package/dist/utils/setup/init-social.d.ts +27 -0
- package/dist/utils/setup/init-social.d.ts.map +1 -1
- package/dist/utils/setup/init-social.js +49 -0
- package/dist/utils/setup/init-social.js.map +1 -1
- package/dist/utils/setup/init-storage.d.ts +22 -0
- package/dist/utils/setup/init-storage.d.ts.map +1 -1
- package/dist/utils/setup/init-storage.js +36 -0
- package/dist/utils/setup/init-storage.js.map +1 -1
- package/dist/utils/setup/register-mfa.d.ts +22 -0
- package/dist/utils/setup/register-mfa.d.ts.map +1 -1
- package/dist/utils/setup/register-mfa.js +41 -0
- package/dist/utils/setup/register-mfa.js.map +1 -1
- package/dist/utils/setup/run-nauth-migrations.d.ts +7 -0
- package/dist/utils/setup/run-nauth-migrations.d.ts.map +1 -1
- package/dist/utils/setup/run-nauth-migrations.js +8 -0
- package/dist/utils/setup/run-nauth-migrations.js.map +1 -1
- package/dist/utils/token-delivery-policy.d.ts +17 -0
- package/dist/utils/token-delivery-policy.d.ts.map +1 -1
- package/dist/utils/token-delivery-policy.js +17 -0
- package/dist/utils/token-delivery-policy.js.map +1 -1
- package/dist/utils.d.ts +8 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +8 -0
- package/dist/utils.js.map +1 -1
- package/dist/validators/template.validator.d.ts +80 -0
- package/dist/validators/template.validator.d.ts.map +1 -1
- package/dist/validators/template.validator.js +94 -0
- package/dist/validators/template.validator.js.map +1 -1
- package/package.json +7 -2
|
@@ -47,6 +47,14 @@ const error_codes_enum_1 = require("../enums/error-codes.enum");
|
|
|
47
47
|
const mfa_method_enum_1 = require("../enums/mfa-method.enum");
|
|
48
48
|
const class_validator_1 = require("class-validator");
|
|
49
49
|
const crypto = __importStar(require("crypto"));
|
|
50
|
+
/**
|
|
51
|
+
* Dummy Argon2 hash for constant-time response
|
|
52
|
+
*
|
|
53
|
+
* SECURITY CRITICAL: Used when user doesn't exist to prevent timing attacks
|
|
54
|
+
* This dummy hash has same format/cost as real Argon2id hashes but verifies against nothing.
|
|
55
|
+
*
|
|
56
|
+
* Format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
|
|
57
|
+
*/
|
|
50
58
|
const DUMMY_ARGON2_HASH = '$argon2id$v=19$m=65536,t=3,p=4$RFVNTVlfU0FMVF9GT1JfVElNSU5H$dummyhashfordummyhashfordummyhash1234567890';
|
|
51
59
|
class AuthService {
|
|
52
60
|
userRepository;
|
|
@@ -67,7 +75,12 @@ class AuthService {
|
|
|
67
75
|
mfaDeviceRepository;
|
|
68
76
|
trustedDeviceService;
|
|
69
77
|
passwordResetService;
|
|
70
|
-
constructor(userRepository, loginAttemptRepository, passwordService, jwtService, sessionService, challengeService, challengeHelper, emailVerificationService, clientInfoService, accountLockoutStorage, config, logger, auditService,
|
|
78
|
+
constructor(userRepository, loginAttemptRepository, passwordService, jwtService, sessionService, challengeService, challengeHelper, emailVerificationService, clientInfoService, accountLockoutStorage, config, logger, auditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
|
|
79
|
+
phoneVerificationService, // Optional - only available when SMS provider is configured
|
|
80
|
+
mfaService, // Optional - available when MFA modules are imported
|
|
81
|
+
mfaDeviceRepository, // Optional - available when MFA modules are imported
|
|
82
|
+
trustedDeviceService, // Optional - only available when rememberDevices is not 'never'
|
|
83
|
+
passwordResetService) {
|
|
71
84
|
this.userRepository = userRepository;
|
|
72
85
|
this.loginAttemptRepository = loginAttemptRepository;
|
|
73
86
|
this.passwordService = passwordService;
|
|
@@ -88,14 +101,39 @@ class AuthService {
|
|
|
88
101
|
this.passwordResetService = passwordResetService;
|
|
89
102
|
this.logger?.log?.('AuthService initialized');
|
|
90
103
|
}
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// User Signup
|
|
106
|
+
// ============================================================================
|
|
107
|
+
/**
|
|
108
|
+
* Register a new user.
|
|
109
|
+
*
|
|
110
|
+
* Checks for duplicates (email, username, phone), validates password, hashes it,
|
|
111
|
+
* creates the user, and returns tokens or a challenge if verification is required.
|
|
112
|
+
*
|
|
113
|
+
* @param dto - Signup payload
|
|
114
|
+
* @returns Auth response with tokens or a verification challenge
|
|
115
|
+
* @throws {NAuthException} If user exists, password is invalid, or signup is disabled
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const result = await authService.signup({
|
|
120
|
+
* email: 'user@example.com',
|
|
121
|
+
* password: 'Password123!',
|
|
122
|
+
* username: 'johndoe',
|
|
123
|
+
* });
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
91
126
|
async signup(dto) {
|
|
127
|
+
// Get client info from request context (transparent!)
|
|
92
128
|
const clientInfo = this.clientInfoService.get();
|
|
93
129
|
this.logger?.log?.(`Signup attempt for email: ${dto.email}`);
|
|
94
130
|
this.logger?.debug?.(`Signup details: { email: ${dto.email}, username: ${dto.username || 'none'}, ip: ${clientInfo.ipAddress} }`);
|
|
131
|
+
// Check if signup is enabled
|
|
95
132
|
if (this.config.signup?.enabled === false) {
|
|
96
133
|
this.logger?.warn?.(`Signup blocked - signup is disabled`);
|
|
97
134
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SIGNUP_DISABLED, 'Signups are currently disabled');
|
|
98
135
|
}
|
|
136
|
+
// Check if user already exists (email and username)
|
|
99
137
|
this.logger?.debug?.(`Checking if user exists: ${dto.email}`);
|
|
100
138
|
const existingUserByEmail = await this.userRepository.findOne({
|
|
101
139
|
where: { email: dto.email },
|
|
@@ -104,6 +142,7 @@ class AuthService {
|
|
|
104
142
|
this.logger?.warn?.(`Signup failed - user already exists: ${dto.email}`);
|
|
105
143
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
|
|
106
144
|
}
|
|
145
|
+
// Check for duplicate username if provided
|
|
107
146
|
if (dto.username) {
|
|
108
147
|
this.logger?.debug?.(`Checking if username exists: ${dto.username}`);
|
|
109
148
|
const existingUserByUsername = await this.userRepository.findOne({
|
|
@@ -114,6 +153,7 @@ class AuthService {
|
|
|
114
153
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
|
|
115
154
|
}
|
|
116
155
|
}
|
|
156
|
+
// Check for duplicate phone if provided and duplicates not allowed
|
|
117
157
|
if (dto.phone && !this.config.signup?.allowDuplicatePhones) {
|
|
118
158
|
this.logger?.debug?.(`Checking if phone exists: ${dto.phone}`);
|
|
119
159
|
const existingUserByPhone = await this.userRepository.findOne({
|
|
@@ -124,6 +164,7 @@ class AuthService {
|
|
|
124
164
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
|
|
125
165
|
}
|
|
126
166
|
}
|
|
167
|
+
// Validate password policy
|
|
127
168
|
this.logger?.debug?.('Validating password against policy');
|
|
128
169
|
const passwordValidation = await this.passwordService.validatePassword(dto.password, {
|
|
129
170
|
email: dto.email,
|
|
@@ -135,12 +176,19 @@ class AuthService {
|
|
|
135
176
|
errors: passwordValidation.errors,
|
|
136
177
|
});
|
|
137
178
|
}
|
|
179
|
+
// Hash password
|
|
138
180
|
const passwordHash = await this.passwordService.hashPassword(dto.password);
|
|
181
|
+
// Determine verification requirements based on verification method
|
|
139
182
|
const verificationMethod = this.config.signup?.verificationMethod;
|
|
183
|
+
// Validate required fields based on verification method
|
|
140
184
|
if ((verificationMethod === 'phone' || verificationMethod === 'both') && !dto.phone) {
|
|
141
185
|
this.logger?.warn?.(`Signup failed - phone required for verification method: ${verificationMethod}`);
|
|
142
186
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PHONE_REQUIRED, 'Phone number is required for the selected verification method', { verificationMethod });
|
|
143
187
|
}
|
|
188
|
+
// Create user
|
|
189
|
+
// Users are always created as ACTIVE (so they can complete pending challenges)
|
|
190
|
+
// Verification status controls access via challenge system, not account activation
|
|
191
|
+
// Email and phone verification status is always false initially - must be explicitly verified
|
|
144
192
|
this.logger?.debug?.(`Creating user record for: ${dto.email} || ${dto.username} || ${dto.phone}`);
|
|
145
193
|
const user = this.userRepository.create({
|
|
146
194
|
email: dto.email,
|
|
@@ -150,21 +198,25 @@ class AuthService {
|
|
|
150
198
|
phone: dto.phone,
|
|
151
199
|
passwordHash,
|
|
152
200
|
passwordChangedAt: new Date(),
|
|
153
|
-
isEmailVerified: false,
|
|
154
|
-
isPhoneVerified: false,
|
|
155
|
-
isActive: true,
|
|
201
|
+
isEmailVerified: false, // Always false initially - must be explicitly verified
|
|
202
|
+
isPhoneVerified: false, // Always false initially - must be verified via SMS
|
|
203
|
+
isActive: true, // Always active - challenges control access instead
|
|
156
204
|
metadata: dto.metadata,
|
|
157
205
|
});
|
|
158
206
|
let savedUser;
|
|
159
207
|
try {
|
|
160
208
|
savedUser = (await this.userRepository.save(user));
|
|
161
209
|
this.logger?.log?.(`User created successfully: ${dto.email} (sub: ${savedUser.sub})`);
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Audit: Record account creation
|
|
212
|
+
// ============================================================================
|
|
162
213
|
try {
|
|
163
214
|
await this.auditService?.recordEvent({
|
|
164
215
|
userId: savedUser.id,
|
|
165
216
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_CREATED,
|
|
166
217
|
eventStatus: 'INFO',
|
|
167
218
|
authMethod: 'password',
|
|
219
|
+
// Client info automatically included from context
|
|
168
220
|
metadata: {
|
|
169
221
|
email: savedUser.email,
|
|
170
222
|
username: savedUser.username || null,
|
|
@@ -173,6 +225,7 @@ class AuthService {
|
|
|
173
225
|
});
|
|
174
226
|
}
|
|
175
227
|
catch (auditError) {
|
|
228
|
+
// Non-blocking: Log but continue
|
|
176
229
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
177
230
|
this.logger?.error?.(`Failed to record ACCOUNT_CREATED audit event: ${errorMessage}`, {
|
|
178
231
|
error: auditError,
|
|
@@ -181,7 +234,9 @@ class AuthService {
|
|
|
181
234
|
}
|
|
182
235
|
}
|
|
183
236
|
catch (error) {
|
|
237
|
+
// Handle database constraint violations gracefully
|
|
184
238
|
if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
|
|
239
|
+
// PostgreSQL unique constraint violation
|
|
185
240
|
const dbError = error;
|
|
186
241
|
if (dbError.detail?.includes('email')) {
|
|
187
242
|
this.logger?.warn?.(`Signup failed - email constraint violation: ${dto.email}`);
|
|
@@ -202,13 +257,24 @@ class AuthService {
|
|
|
202
257
|
});
|
|
203
258
|
}
|
|
204
259
|
}
|
|
260
|
+
// Re-throw other database errors
|
|
205
261
|
const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
|
|
206
262
|
this.logger?.error?.(`Signup failed - database error: ${errorMessage}`);
|
|
207
263
|
throw error;
|
|
208
264
|
}
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Verification Code Sending: Handled by challenge system (sequential flow)
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// All verification codes are sent when challenges are created (in AuthChallengeHelperService.createChallengeResponse)
|
|
269
|
+
// This ensures proper sequential flow: email code first, then phone code after email is verified
|
|
270
|
+
// This prevents user confusion from receiving multiple codes at once
|
|
271
|
+
// Execute afterSignup hook if configured
|
|
209
272
|
if (this.config.hooks?.afterSignup) {
|
|
210
273
|
await this.config.hooks.afterSignup(savedUser, { requiresVerification: verificationMethod !== 'none' });
|
|
211
274
|
}
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// Challenge System: Determine if user needs to complete challenges
|
|
277
|
+
// ============================================================================
|
|
212
278
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
213
279
|
user: savedUser,
|
|
214
280
|
config: this.config,
|
|
@@ -222,11 +288,34 @@ class AuthService {
|
|
|
222
288
|
}
|
|
223
289
|
return response;
|
|
224
290
|
}
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// User Login
|
|
293
|
+
// ============================================================================
|
|
294
|
+
/**
|
|
295
|
+
* Log in a user with identifier (email, username, or phone) and password.
|
|
296
|
+
*
|
|
297
|
+
* Handles client/device context, login hooks, lockout checks, audit logging, password verification,
|
|
298
|
+
* and challenge flow (MFA/verification) if required.
|
|
299
|
+
*
|
|
300
|
+
* @param dto - Login credentials (identifier and password)
|
|
301
|
+
* @returns Authentication response containing challenge details if required, or tokens on success
|
|
302
|
+
* @throws {NAuthException} On login failure, forbidden access, or account lockout
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* ```typescript
|
|
306
|
+
* const res = await authService.login({ identifier: 'user@email.com', password: 'Pass123!' });
|
|
307
|
+
* if (res.challengeName) {
|
|
308
|
+
* // prompt user for verification code
|
|
309
|
+
* }
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
225
312
|
async login(dto) {
|
|
313
|
+
// Get client info from request context (transparent!)
|
|
226
314
|
const clientInfo = this.clientInfoService.get();
|
|
227
315
|
const fireAndForget = this.config.auditLogs?.fireAndForget === true;
|
|
228
316
|
this.logger?.log?.(`Login attempt for: ${dto.identifier}`);
|
|
229
317
|
this.logger?.debug?.(`Login details: { identifier: ${dto.identifier}, ip: ${clientInfo.ipAddress}, deviceToken: ${clientInfo.deviceToken ? 'present' : 'none'} }`);
|
|
318
|
+
// Check IP-based account lockout
|
|
230
319
|
if (this.config.lockout?.enabled) {
|
|
231
320
|
const clientInfo = this.clientInfoService.get();
|
|
232
321
|
const ipAddress = clientInfo.ipAddress;
|
|
@@ -236,6 +325,9 @@ class AuthService {
|
|
|
236
325
|
if (isLocked) {
|
|
237
326
|
this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
|
|
238
327
|
await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Audit: Record blocked login (IP locked)
|
|
330
|
+
// ============================================================================
|
|
239
331
|
if (fireAndForget) {
|
|
240
332
|
this.auditService
|
|
241
333
|
?.recordEvent({
|
|
@@ -276,6 +368,9 @@ class AuthService {
|
|
|
276
368
|
}
|
|
277
369
|
}
|
|
278
370
|
}
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Validate identifier type based on configuration
|
|
373
|
+
// ============================================================================
|
|
279
374
|
const identifierType = this.config.login?.identifierType;
|
|
280
375
|
if (identifierType) {
|
|
281
376
|
this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
|
|
@@ -286,14 +381,22 @@ class AuthService {
|
|
|
286
381
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, `Login with this identifier type is not allowed. Expected: ${identifierType}`);
|
|
287
382
|
}
|
|
288
383
|
}
|
|
384
|
+
// Find user by email, username, or phone (filtered by identifierType config)
|
|
289
385
|
this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
|
|
290
386
|
const user = await this.findUserByIdentifier(dto.identifier, identifierType);
|
|
387
|
+
// SECURITY CRITICAL: Always hash password even when user doesn't exist
|
|
388
|
+
// This ensures constant-time response to prevent user enumeration via timing attacks
|
|
291
389
|
const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
|
|
390
|
+
// Verify password (takes ~200-300ms regardless of user existence)
|
|
292
391
|
this.logger?.debug?.('Verifying password');
|
|
293
392
|
const isPasswordValid = await this.passwordService.verifyPassword(dto.password, hashToVerify);
|
|
393
|
+
// Now check all conditions AFTER password verification (constant time achieved)
|
|
294
394
|
if (!user || !user.passwordHash || !isPasswordValid) {
|
|
295
395
|
this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
|
|
296
396
|
await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// Audit: Record failed login
|
|
399
|
+
// ============================================================================
|
|
297
400
|
if (user) {
|
|
298
401
|
if (fireAndForget) {
|
|
299
402
|
this.auditService
|
|
@@ -334,6 +437,7 @@ class AuthService {
|
|
|
334
437
|
}
|
|
335
438
|
}
|
|
336
439
|
}
|
|
440
|
+
// Provide helpful error if user exists but has no password (social-only account)
|
|
337
441
|
if (user && !user.passwordHash && user.socialProviders && user.socialProviders.length > 0) {
|
|
338
442
|
const provider = user.socialProviders[0];
|
|
339
443
|
const providerName = provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
@@ -343,6 +447,9 @@ class AuthService {
|
|
|
343
447
|
}
|
|
344
448
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, 'Invalid credentials');
|
|
345
449
|
}
|
|
450
|
+
// ============================================================================
|
|
451
|
+
// Password Expiry Check
|
|
452
|
+
// ============================================================================
|
|
346
453
|
const expiryDays = this.config.password?.expiryDays;
|
|
347
454
|
if (expiryDays && expiryDays > 0 && user.passwordChangedAt) {
|
|
348
455
|
const expiryDate = new Date(user.passwordChangedAt);
|
|
@@ -350,10 +457,13 @@ class AuthService {
|
|
|
350
457
|
const now = new Date();
|
|
351
458
|
if (now > expiryDate) {
|
|
352
459
|
this.logger?.warn?.(`Password expired for user: ${user.sub}. Changed: ${user.passwordChangedAt}, Expiry: ${expiryDate}`);
|
|
460
|
+
// Force password change by setting mustChangePassword flag
|
|
353
461
|
await this.userRepository.update(user.id, {
|
|
354
462
|
mustChangePassword: true,
|
|
355
463
|
});
|
|
464
|
+
// Update in-memory user reference to include mustChangePassword
|
|
356
465
|
user.mustChangePassword = true;
|
|
466
|
+
// Check challenges - FORCE_CHANGE_PASSWORD will be included
|
|
357
467
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
358
468
|
user,
|
|
359
469
|
config: this.config,
|
|
@@ -366,6 +476,11 @@ class AuthService {
|
|
|
366
476
|
}
|
|
367
477
|
}
|
|
368
478
|
}
|
|
479
|
+
// ============================================================================
|
|
480
|
+
// Audit: Record login attempt for successful password verification
|
|
481
|
+
// ============================================================================
|
|
482
|
+
// Record LOGIN_ATTEMPT for all successful password verifications
|
|
483
|
+
// IMPORTANT: Always await this to ensure correct chronological order before risk assessment
|
|
369
484
|
try {
|
|
370
485
|
await this.auditService?.recordEvent({
|
|
371
486
|
userId: user.id,
|
|
@@ -376,18 +491,25 @@ class AuthService {
|
|
|
376
491
|
});
|
|
377
492
|
}
|
|
378
493
|
catch (auditError) {
|
|
494
|
+
// Non-blocking: Log but continue even if audit fails
|
|
379
495
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
380
496
|
this.logger?.error?.(`Failed to record LOGIN_ATTEMPT audit event: ${errorMessage}`, {
|
|
381
497
|
error: auditError,
|
|
382
498
|
userId: user.id,
|
|
383
499
|
});
|
|
384
500
|
}
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Challenge System: Determine authentication response using state machine
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// All challenge determination is now handled by state machine in determineAuthResponse
|
|
505
|
+
// This replaces the old determinePendingChallenges and checkMFARequirement methods
|
|
385
506
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
386
507
|
user,
|
|
387
508
|
config: this.config,
|
|
388
509
|
deviceToken: clientInfo.deviceToken,
|
|
389
510
|
isSocialLogin: false,
|
|
390
511
|
});
|
|
512
|
+
// If challenge is required, record login attempt and return challenge
|
|
391
513
|
if (response.challengeName) {
|
|
392
514
|
const reasonMap = {
|
|
393
515
|
[auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL]: 'verification_required',
|
|
@@ -400,15 +522,20 @@ class AuthService {
|
|
|
400
522
|
await this.recordLoginAttempt(dto.identifier, false, reasonMap[response.challengeName] || 'challenge_required', user.id);
|
|
401
523
|
return response;
|
|
402
524
|
}
|
|
525
|
+
// If response already has tokens (session was created by challenge helper), return it
|
|
526
|
+
// This prevents duplicate session creation
|
|
403
527
|
if (response.accessToken && response.refreshToken) {
|
|
404
528
|
this.logger?.debug?.(`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`);
|
|
529
|
+
// Record successful login attempt
|
|
405
530
|
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
406
531
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
532
|
+
// Update user last login info
|
|
407
533
|
await this.userRepository.update(user.id, {
|
|
408
534
|
lastLoginAt: new Date(),
|
|
409
535
|
lastLoginIp: clientInfo.ipAddress,
|
|
410
536
|
failedLoginAttempts: 0,
|
|
411
537
|
});
|
|
538
|
+
// Reset IP-based failed attempts on successful login
|
|
412
539
|
if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
|
|
413
540
|
const ipAddress = clientInfo.ipAddress;
|
|
414
541
|
if (ipAddress) {
|
|
@@ -416,6 +543,7 @@ class AuthService {
|
|
|
416
543
|
await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
|
|
417
544
|
}
|
|
418
545
|
}
|
|
546
|
+
// Extract session ID and device info from token to record audit event
|
|
419
547
|
let sessionId;
|
|
420
548
|
let deviceId;
|
|
421
549
|
try {
|
|
@@ -423,6 +551,7 @@ class AuthService {
|
|
|
423
551
|
if (tokenPayload?.sessionId) {
|
|
424
552
|
sessionId = parseInt(String(tokenPayload.sessionId), 10);
|
|
425
553
|
}
|
|
554
|
+
// Get deviceId from session if available
|
|
426
555
|
if (sessionId) {
|
|
427
556
|
const session = await this.sessionService.findById(sessionId);
|
|
428
557
|
if (session && session.deviceId) {
|
|
@@ -431,11 +560,14 @@ class AuthService {
|
|
|
431
560
|
}
|
|
432
561
|
}
|
|
433
562
|
catch {
|
|
563
|
+
// Non-blocking: Continue without sessionId/deviceId
|
|
434
564
|
this.logger?.debug?.('Failed to extract sessionId/deviceId from token for audit');
|
|
435
565
|
}
|
|
566
|
+
// Determine trusted device and MFA bypass status from response
|
|
436
567
|
const isTrustedDevice = response.trusted || false;
|
|
437
|
-
const mfaBypassed = false;
|
|
568
|
+
const mfaBypassed = false; // Challenge helper handles MFA, so if we get here, MFA was not bypassed
|
|
438
569
|
const mfaBypassReason = null;
|
|
570
|
+
// Record successful login audit event
|
|
439
571
|
if (fireAndForget) {
|
|
440
572
|
this.auditService
|
|
441
573
|
?.recordEvent({
|
|
@@ -478,6 +610,9 @@ class AuthService {
|
|
|
478
610
|
}
|
|
479
611
|
return response;
|
|
480
612
|
}
|
|
613
|
+
// ============================================================================
|
|
614
|
+
// Trusted Device Status Check (for audit metadata)
|
|
615
|
+
// ============================================================================
|
|
481
616
|
let isTrustedDevice = false;
|
|
482
617
|
let mfaBypassed = false;
|
|
483
618
|
let mfaBypassReason = null;
|
|
@@ -487,20 +622,33 @@ class AuthService {
|
|
|
487
622
|
clientInfo.deviceToken) {
|
|
488
623
|
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
|
|
489
624
|
}
|
|
625
|
+
// Check if user is exempt from MFA
|
|
490
626
|
const userEntityDebug = user;
|
|
491
627
|
const userMfaExempt = userEntityDebug.mfaExempt === true || userEntityDebug.mfaExempt === 'true';
|
|
628
|
+
// Determine if MFA was bypassed
|
|
629
|
+
// MFA is bypassed if:
|
|
630
|
+
// 1. No challenge was returned (meaning MFA was skipped)
|
|
631
|
+
// 2. MFA would have been required otherwise
|
|
632
|
+
// 3. Either:
|
|
633
|
+
// a. Device is trusted AND bypassMFAForTrustedDevices is enabled (trusted device bypass)
|
|
634
|
+
// b. User has mfaExempt = true (MFA exemption bypass)
|
|
492
635
|
if (!response.challengeName && this.config.mfa) {
|
|
493
636
|
const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
|
|
637
|
+
// MFA would be required if:
|
|
638
|
+
// - OPTIONAL enforcement AND user has MFA enabled, OR
|
|
639
|
+
// - REQUIRED/ADAPTIVE enforcement (regardless of user.mfaEnabled for REQUIRED)
|
|
494
640
|
const wouldRequireMFA = (enforcement === 'OPTIONAL' && user.mfaEnabled) || enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE';
|
|
495
641
|
if (wouldRequireMFA) {
|
|
642
|
+
// Check if bypassed due to trusted device
|
|
496
643
|
if (isTrustedDevice &&
|
|
497
644
|
this.config.mfa.bypassMFAForTrustedDevices === true &&
|
|
498
|
-
enforcement !== 'ADAPTIVE' &&
|
|
645
|
+
enforcement !== 'ADAPTIVE' && // Adaptive MFA could bypass it anyway if device is trusted but requires different logging
|
|
499
646
|
!userMfaExempt) {
|
|
500
647
|
mfaBypassed = true;
|
|
501
648
|
mfaBypassReason = 'trusted_device';
|
|
502
649
|
this.logger?.debug?.(`MFA bypassed for trusted device - user ${user.sub}`);
|
|
503
650
|
}
|
|
651
|
+
// Check if bypassed due to MFA exemption
|
|
504
652
|
else if (userMfaExempt) {
|
|
505
653
|
mfaBypassed = true;
|
|
506
654
|
mfaBypassReason = 'mfa_exempt';
|
|
@@ -508,9 +656,15 @@ class AuthService {
|
|
|
508
656
|
}
|
|
509
657
|
}
|
|
510
658
|
}
|
|
659
|
+
// MFA challenge is already handled by determineAuthResponse above
|
|
660
|
+
// If response.challengeName is set, it was already returned
|
|
661
|
+
// Check if user is active (should never happen with new signups, but keep for legacy accounts)
|
|
511
662
|
if (!user.isActive) {
|
|
512
663
|
this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
|
|
513
664
|
await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
|
|
665
|
+
// ============================================================================
|
|
666
|
+
// Audit: Record blocked login (account inactive)
|
|
667
|
+
// ============================================================================
|
|
514
668
|
try {
|
|
515
669
|
await this.auditService?.recordEvent({
|
|
516
670
|
userId: user.id,
|
|
@@ -519,9 +673,11 @@ class AuthService {
|
|
|
519
673
|
authMethod: 'password',
|
|
520
674
|
reason: 'account_inactive',
|
|
521
675
|
description: 'Login blocked - account is inactive',
|
|
676
|
+
// Client info automatically included from context
|
|
522
677
|
});
|
|
523
678
|
}
|
|
524
679
|
catch (auditError) {
|
|
680
|
+
// Non-blocking: Log but continue
|
|
525
681
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
526
682
|
this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (account inactive): ${errorMessage}`, {
|
|
527
683
|
error: auditError,
|
|
@@ -530,6 +686,7 @@ class AuthService {
|
|
|
530
686
|
}
|
|
531
687
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is inactive. Please contact support.');
|
|
532
688
|
}
|
|
689
|
+
// Reset IP-based failed attempts on successful login
|
|
533
690
|
if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
|
|
534
691
|
const ipAddress = clientInfo.ipAddress;
|
|
535
692
|
if (ipAddress) {
|
|
@@ -537,9 +694,19 @@ class AuthService {
|
|
|
537
694
|
await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
|
|
538
695
|
}
|
|
539
696
|
}
|
|
697
|
+
// ============================================================================
|
|
698
|
+
// Generate Device ID Server-Side (Security: Never accept from client)
|
|
699
|
+
// ============================================================================
|
|
700
|
+
// Always generate device ID server-side (no client input accepted)
|
|
701
|
+
// This device ID is used for session tracking, not for trusted device feature
|
|
702
|
+
// Trusted devices use separate deviceToken (generated after MFA verification)
|
|
540
703
|
const validatedDeviceId = crypto.randomUUID();
|
|
541
704
|
this.logger?.debug?.(`Generated server-side deviceId: ${validatedDeviceId}`);
|
|
705
|
+
// Generate token family for rotation tracking
|
|
542
706
|
const tokenFamily = this.jwtService.generateTokenFamily();
|
|
707
|
+
// ============================================================================
|
|
708
|
+
// Single Session Mode: Revoke other sessions if disallowMultipleSessions is enabled
|
|
709
|
+
// ============================================================================
|
|
543
710
|
if (this.config.session?.disallowMultipleSessions) {
|
|
544
711
|
this.logger?.debug?.(`Single session mode enabled - revoking other sessions for user: ${user.sub}`);
|
|
545
712
|
const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Login from new session');
|
|
@@ -547,6 +714,7 @@ class AuthService {
|
|
|
547
714
|
this.logger?.log?.(`Revoked ${revokedCount} other active session(s) for user: ${user.sub}`);
|
|
548
715
|
}
|
|
549
716
|
}
|
|
717
|
+
// Atomically create session and persist token hashes
|
|
550
718
|
this.logger?.debug?.(`Creating login session for user: ${user.sub}`);
|
|
551
719
|
const atomic = await this.sessionService.createSessionAtomic({
|
|
552
720
|
userId: user.id,
|
|
@@ -554,6 +722,7 @@ class AuthService {
|
|
|
554
722
|
deviceId: validatedDeviceId,
|
|
555
723
|
deviceName: dto.deviceName,
|
|
556
724
|
deviceType: dto.deviceType,
|
|
725
|
+
// Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
|
|
557
726
|
isRemembered: false,
|
|
558
727
|
expiresAt: this.sessionService.getSessionExpirationDate(),
|
|
559
728
|
authMethod: 'password',
|
|
@@ -573,13 +742,18 @@ class AuthService {
|
|
|
573
742
|
const session = atomic.session;
|
|
574
743
|
const tokens = atomic.extra;
|
|
575
744
|
this.logger?.debug?.(`Session created: ${session.id}`);
|
|
745
|
+
// Update user last login info - use internal id for update
|
|
576
746
|
await this.userRepository.update(user.id, {
|
|
577
747
|
lastLoginAt: new Date(),
|
|
578
748
|
lastLoginIp: clientInfo.ipAddress,
|
|
579
749
|
failedLoginAttempts: 0,
|
|
580
750
|
});
|
|
751
|
+
// Record successful login attempt - use internal id
|
|
581
752
|
await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
|
|
582
753
|
this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
|
|
754
|
+
// ============================================================================
|
|
755
|
+
// Audit: Record successful login with trusted device and MFA bypass metadata
|
|
756
|
+
// ============================================================================
|
|
583
757
|
if (fireAndForget) {
|
|
584
758
|
this.auditService
|
|
585
759
|
?.recordEvent({
|
|
@@ -620,17 +794,26 @@ class AuthService {
|
|
|
620
794
|
});
|
|
621
795
|
}
|
|
622
796
|
}
|
|
797
|
+
// // Execute afterLogin hook
|
|
798
|
+
// if (this.config.hooks?.afterLogin) {
|
|
799
|
+
// await this.config.hooks.afterLogin(user, session);
|
|
800
|
+
// }
|
|
801
|
+
// ============================================================================
|
|
802
|
+
// Trusted Device Token Management (Remember Device Feature)
|
|
803
|
+
// ============================================================================
|
|
623
804
|
let deviceToken;
|
|
624
805
|
let isTrusted = false;
|
|
625
806
|
if (this.config.mfa?.rememberDevices && this.config.mfa?.rememberDevices !== 'never' && this.trustedDeviceService) {
|
|
626
807
|
const rememberDevicesMode = this.config.mfa.rememberDevices;
|
|
808
|
+
// Check if device is already trusted
|
|
627
809
|
if (clientInfo.deviceToken) {
|
|
628
810
|
isTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
|
|
629
811
|
if (isTrusted) {
|
|
630
|
-
deviceToken = clientInfo.deviceToken;
|
|
812
|
+
deviceToken = clientInfo.deviceToken; // Reuse existing token
|
|
631
813
|
this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
|
|
632
814
|
}
|
|
633
815
|
}
|
|
816
|
+
// Auto-trust mode: Create device token automatically if not already trusted
|
|
634
817
|
if (rememberDevicesMode === 'always' && !isTrusted) {
|
|
635
818
|
try {
|
|
636
819
|
deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, dto.deviceName || clientInfo.deviceName, dto.deviceType || clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
@@ -638,13 +821,21 @@ class AuthService {
|
|
|
638
821
|
this.logger?.debug?.(`Auto-created trusted device token for user ${user.sub} (always mode)`);
|
|
639
822
|
}
|
|
640
823
|
catch (error) {
|
|
824
|
+
// Non-blocking: Log but continue without device token
|
|
641
825
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
642
826
|
this.logger?.warn?.(`Failed to create trusted device token: ${errorMessage}`, { error });
|
|
643
827
|
}
|
|
644
828
|
}
|
|
829
|
+
// user_opt_in mode: Don't create token here - user must call trust-device endpoint
|
|
830
|
+
// isTrusted flag is already set above if device token exists and is valid
|
|
645
831
|
}
|
|
832
|
+
// Decode tokens to get expiry times
|
|
646
833
|
const accessTokenValidation = await this.jwtService.validateAccessToken(tokens.accessToken);
|
|
647
834
|
const refreshTokenValidation = await this.jwtService.validateRefreshToken(tokens.refreshToken);
|
|
835
|
+
// Return sanitized user object with expiry timestamps
|
|
836
|
+
// Note: deviceToken inclusion in response body is handled by CookieTokenInterceptor
|
|
837
|
+
// which checks route-level @TokenDelivery decorator and global config
|
|
838
|
+
// to decide whether to set as cookie and/or strip from body
|
|
648
839
|
const userDto = user_response_dto_1.UserResponseDto.fromEntity(user);
|
|
649
840
|
const authResponse = {
|
|
650
841
|
user: {
|
|
@@ -661,19 +852,46 @@ class AuthService {
|
|
|
661
852
|
refreshToken: tokens.refreshToken,
|
|
662
853
|
accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
|
|
663
854
|
refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
|
|
664
|
-
trusted: isTrusted,
|
|
855
|
+
trusted: isTrusted, // Include trusted flag so frontend knows if device is already trusted
|
|
856
|
+
// Include deviceToken - CookieTokenInterceptor will handle cookie/stripping based on @TokenDelivery decorator
|
|
665
857
|
deviceToken,
|
|
666
858
|
};
|
|
667
859
|
return authResponse;
|
|
668
860
|
}
|
|
861
|
+
/**
|
|
862
|
+
* Complete an authentication challenge using the provided response data.
|
|
863
|
+
*
|
|
864
|
+
* Handles all challenge types (email verification, phone verification, MFA, password change, MFA setup).
|
|
865
|
+
* Validates the session, challenge type, and parameters, and returns the result (tokens or next challenge).
|
|
866
|
+
*
|
|
867
|
+
* @param responseData - Data for responding to the challenge
|
|
868
|
+
* @returns The authentication response (tokens or next challenge requirement)
|
|
869
|
+
* @throws {NAuthException} If validation fails or the challenge type is unknown
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* ```typescript
|
|
873
|
+
* // Example for email verification:
|
|
874
|
+
* const dto = Object.assign(new RespondChallengeDTO(), {
|
|
875
|
+
* session: 'session-token',
|
|
876
|
+
* type: 'VERIFY_EMAIL',
|
|
877
|
+
* code: '123456',
|
|
878
|
+
* });
|
|
879
|
+
* await authService.respondToChallenge(dto);
|
|
880
|
+
* ```
|
|
881
|
+
*/
|
|
669
882
|
async respondToChallenge(dto) {
|
|
670
883
|
const responseData = dto;
|
|
671
884
|
const { session, type } = responseData;
|
|
672
885
|
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
673
886
|
this.logger?.log?.(`[${requestTrace}] Challenge response received: type=${type}, session=${session?.substring(0, 8)}...`);
|
|
887
|
+
// Validate session and get challenge type
|
|
674
888
|
const challengeSession = await this.challengeService.validateSession(session);
|
|
889
|
+
// Validate response matches expected challenge
|
|
675
890
|
this.validateChallengeTypeMatch(challengeSession.challengeName, type);
|
|
891
|
+
// Validate parameters for this challenge type
|
|
892
|
+
// TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
|
|
676
893
|
this.validateChallengeParams(type, responseData);
|
|
894
|
+
// Handle challenge based on type
|
|
677
895
|
switch (type) {
|
|
678
896
|
case 'VERIFY_EMAIL':
|
|
679
897
|
return await this.handleVerifyEmail(challengeSession, responseData.code);
|
|
@@ -689,11 +907,20 @@ class AuthService {
|
|
|
689
907
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
|
|
690
908
|
}
|
|
691
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Validate that response type matches expected challenge type
|
|
912
|
+
*/
|
|
692
913
|
validateChallengeTypeMatch(expected, provided) {
|
|
693
914
|
if (expected !== provided) {
|
|
694
915
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Challenge type mismatch: expected ${expected}, got ${provided}`);
|
|
695
916
|
}
|
|
696
917
|
}
|
|
918
|
+
/**
|
|
919
|
+
* Validate parameters for challenge type
|
|
920
|
+
*
|
|
921
|
+
* Service-level validation ensures Express/other frameworks get same validation as NestJS.
|
|
922
|
+
* This is critical for non-DTO-based applications.
|
|
923
|
+
*/
|
|
697
924
|
validateChallengeParams(type, data) {
|
|
698
925
|
switch (type) {
|
|
699
926
|
case 'VERIFY_EMAIL': {
|
|
@@ -758,32 +985,42 @@ class AuthService {
|
|
|
758
985
|
}
|
|
759
986
|
}
|
|
760
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Handle VERIFY_EMAIL challenge
|
|
990
|
+
*/
|
|
761
991
|
async handleVerifyEmail(challengeSession, code) {
|
|
762
992
|
const user = challengeSession.user;
|
|
763
993
|
if (!user) {
|
|
764
994
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
765
995
|
}
|
|
766
996
|
this.logger?.log?.(`Verifying email for user: ${user.sub}`);
|
|
997
|
+
// Verify email with code, ensuring it belongs to this specific challenge session
|
|
767
998
|
const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
|
|
768
999
|
email: user.email,
|
|
769
1000
|
code,
|
|
770
|
-
challengeSessionId: challengeSession.id,
|
|
1001
|
+
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
771
1002
|
});
|
|
772
1003
|
const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
|
|
773
1004
|
const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
|
|
774
1005
|
if (!isVerified) {
|
|
1006
|
+
// Increment attempts but don't consume session
|
|
775
1007
|
await this.challengeService.incrementAttempts(challengeSession);
|
|
776
1008
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
777
1009
|
}
|
|
1010
|
+
// Consume challenge session
|
|
778
1011
|
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
|
|
1012
|
+
// Reload user to get updated emailVerified flag
|
|
779
1013
|
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
780
1014
|
if (!updatedUser) {
|
|
781
1015
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
|
|
782
1016
|
}
|
|
1017
|
+
// Get client info
|
|
783
1018
|
const clientInfo = this.clientInfoService.get();
|
|
1019
|
+
// Read auth context from challenge session metadata
|
|
784
1020
|
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
785
1021
|
const authProvider = challengeSession.metadata?.authProvider;
|
|
786
1022
|
const isSocialLogin = authMethod === 'social';
|
|
1023
|
+
// Check for next challenges
|
|
787
1024
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
788
1025
|
user: updatedUser,
|
|
789
1026
|
config: this.config,
|
|
@@ -800,28 +1037,36 @@ class AuthService {
|
|
|
800
1037
|
}
|
|
801
1038
|
return response;
|
|
802
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Handle VERIFY_PHONE challenge
|
|
1042
|
+
*/
|
|
803
1043
|
async handleVerifyPhone(challengeSession, data) {
|
|
804
1044
|
const user = challengeSession.user;
|
|
805
1045
|
if (!user) {
|
|
806
1046
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
|
|
807
1047
|
}
|
|
1048
|
+
// Check if this is phone collection (first step) or verification (second step)
|
|
808
1049
|
if ('phone' in data && data.phone) {
|
|
1050
|
+
// Phone collection step
|
|
809
1051
|
const phone = data.phone;
|
|
810
1052
|
this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
|
|
1053
|
+
// Validate phone format (E.164 format: +[country][number])
|
|
811
1054
|
const phoneRegex = /^\+[1-9]\d{1,14}$/;
|
|
812
1055
|
if (!phoneRegex.test(phone)) {
|
|
813
1056
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_PHONE_FORMAT, 'Invalid phone number format. Use E.164 format (e.g., +1234567890)');
|
|
814
1057
|
}
|
|
1058
|
+
// Update user phone number
|
|
815
1059
|
await this.userRepository.update({ sub: user.sub }, { phone });
|
|
816
1060
|
this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
|
|
1061
|
+
// Send verification SMS to the newly added phone
|
|
817
1062
|
let smsError;
|
|
818
1063
|
if (this.phoneVerificationService) {
|
|
819
1064
|
this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
|
|
820
1065
|
try {
|
|
821
1066
|
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
822
1067
|
sub: user.sub,
|
|
823
|
-
skipAlreadyVerifiedCheck: false,
|
|
824
|
-
challengeSessionId: challengeSession.id,
|
|
1068
|
+
skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
|
|
1069
|
+
challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
|
|
825
1070
|
});
|
|
826
1071
|
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
827
1072
|
this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
|
|
@@ -836,9 +1081,14 @@ class AuthService {
|
|
|
836
1081
|
this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
|
|
837
1082
|
'Phone verification requires an SMS provider to be configured.');
|
|
838
1083
|
}
|
|
1084
|
+
// DO NOT consume the challenge session yet - user still needs to verify the code
|
|
1085
|
+
// Preserve auth context from original challenge session
|
|
839
1086
|
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
840
1087
|
const authProvider = challengeSession.metadata?.authProvider;
|
|
1088
|
+
// Return same challenge with updated phone in parameters
|
|
1089
|
+
// Skip auto-send since SMS was already sent above during phone collection
|
|
841
1090
|
const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
|
|
1091
|
+
// Include SMS error in challenge parameters if SMS failed
|
|
842
1092
|
if (smsError) {
|
|
843
1093
|
challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
|
|
844
1094
|
challengeResponse.challengeParameters.smsError = smsError;
|
|
@@ -846,31 +1096,40 @@ class AuthService {
|
|
|
846
1096
|
return challengeResponse;
|
|
847
1097
|
}
|
|
848
1098
|
else {
|
|
1099
|
+
// Phone verification step (code provided)
|
|
849
1100
|
const code = data.code;
|
|
850
1101
|
this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
|
|
1102
|
+
// Check if phone is set
|
|
851
1103
|
if (!user.phone) {
|
|
852
1104
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
853
1105
|
}
|
|
1106
|
+
// Verify phone with code, ensuring it belongs to this specific challenge session
|
|
854
1107
|
const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
|
|
855
1108
|
sub: user.sub,
|
|
856
1109
|
code,
|
|
857
|
-
challengeSessionId: challengeSession.id,
|
|
1110
|
+
challengeSessionId: challengeSession.id, // Link verification to this specific session
|
|
858
1111
|
});
|
|
859
1112
|
const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
|
|
860
1113
|
const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
|
|
861
1114
|
if (!isVerified) {
|
|
1115
|
+
// Increment attempts but don't consume session
|
|
862
1116
|
await this.challengeService.incrementAttempts(challengeSession);
|
|
863
1117
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
|
|
864
1118
|
}
|
|
1119
|
+
// Consume challenge session
|
|
865
1120
|
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
|
|
1121
|
+
// Reload user to get updated phoneVerified flag
|
|
866
1122
|
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
867
1123
|
if (!updatedUser) {
|
|
868
1124
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
|
|
869
1125
|
}
|
|
1126
|
+
// Get client info
|
|
870
1127
|
const clientInfo = this.clientInfoService.get();
|
|
1128
|
+
// Read auth context from challenge session metadata
|
|
871
1129
|
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
872
1130
|
const authProvider = challengeSession.metadata?.authProvider;
|
|
873
1131
|
const isSocialLogin = authMethod === 'social';
|
|
1132
|
+
// Check for next challenges
|
|
874
1133
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
875
1134
|
user: updatedUser,
|
|
876
1135
|
config: this.config,
|
|
@@ -884,6 +1143,9 @@ class AuthService {
|
|
|
884
1143
|
}
|
|
885
1144
|
else {
|
|
886
1145
|
this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
|
|
1146
|
+
// ============================================================================
|
|
1147
|
+
// Audit: Record successful login after phone verification
|
|
1148
|
+
// ============================================================================
|
|
887
1149
|
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
888
1150
|
if (fireAndForget) {
|
|
889
1151
|
this.auditService
|
|
@@ -929,6 +1191,9 @@ class AuthService {
|
|
|
929
1191
|
return response;
|
|
930
1192
|
}
|
|
931
1193
|
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Handle MFA_REQUIRED challenge
|
|
1196
|
+
*/
|
|
932
1197
|
async handleMFAVerification(challengeSession, data) {
|
|
933
1198
|
const user = challengeSession.user;
|
|
934
1199
|
if (!user) {
|
|
@@ -936,18 +1201,23 @@ class AuthService {
|
|
|
936
1201
|
}
|
|
937
1202
|
const method = data.method;
|
|
938
1203
|
this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
|
|
1204
|
+
// Check if MFAService is available
|
|
939
1205
|
if (!this.mfaService) {
|
|
940
1206
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
941
1207
|
}
|
|
1208
|
+
// Get client info
|
|
942
1209
|
const clientInfo = this.clientInfoService.get();
|
|
1210
|
+
// Verify MFA based on method
|
|
943
1211
|
let isValid = false;
|
|
944
1212
|
if (method === 'passkey') {
|
|
945
1213
|
const passkeyData = data;
|
|
946
1214
|
const credential = passkeyData.credential;
|
|
1215
|
+
// Get expected challenge from session metadata
|
|
947
1216
|
const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
|
|
948
1217
|
if (!expectedChallenge) {
|
|
949
1218
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
|
|
950
1219
|
}
|
|
1220
|
+
// Verify passkey via MFAService
|
|
951
1221
|
const wrappedCredential = { credential, expectedChallenge };
|
|
952
1222
|
const verifyResult = await this.mfaService.verifyCode({
|
|
953
1223
|
sub: user.sub,
|
|
@@ -959,6 +1229,7 @@ class AuthService {
|
|
|
959
1229
|
else {
|
|
960
1230
|
const codeData = data;
|
|
961
1231
|
const code = codeData.code;
|
|
1232
|
+
// Verify code via MFAService (handles totp, sms, and backup)
|
|
962
1233
|
const verifyResult = await this.mfaService.verifyCode({
|
|
963
1234
|
sub: user.sub,
|
|
964
1235
|
methodName: method,
|
|
@@ -968,6 +1239,7 @@ class AuthService {
|
|
|
968
1239
|
}
|
|
969
1240
|
if (!isValid) {
|
|
970
1241
|
this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
|
|
1242
|
+
// Audit: Record MFA verification failure
|
|
971
1243
|
if (this.config.auditLogs?.fireAndForget) {
|
|
972
1244
|
this.auditService
|
|
973
1245
|
?.recordEvent({
|
|
@@ -1006,10 +1278,12 @@ class AuthService {
|
|
|
1006
1278
|
});
|
|
1007
1279
|
}
|
|
1008
1280
|
}
|
|
1281
|
+
// Increment challenge attempts (session not consumed, so user can retry)
|
|
1009
1282
|
await this.challengeService.incrementAttempts(challengeSession);
|
|
1010
1283
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
|
|
1011
1284
|
}
|
|
1012
1285
|
this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
|
|
1286
|
+
// Audit: Record MFA verification success
|
|
1013
1287
|
if (this.config.auditLogs?.fireAndForget) {
|
|
1014
1288
|
this.auditService
|
|
1015
1289
|
?.recordEvent({
|
|
@@ -1048,17 +1322,28 @@ class AuthService {
|
|
|
1048
1322
|
});
|
|
1049
1323
|
}
|
|
1050
1324
|
}
|
|
1325
|
+
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
1051
1326
|
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
1052
1327
|
mfaMethod: method,
|
|
1053
1328
|
});
|
|
1329
|
+
// Only consume the session AFTER successful verification
|
|
1054
1330
|
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
|
|
1331
|
+
// Read auth context from challenge session metadata
|
|
1055
1332
|
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
1056
1333
|
const authProvider = challengeSession.metadata?.authProvider;
|
|
1057
1334
|
const isSocialLogin = authMethod === 'social';
|
|
1335
|
+
// ============================================================================
|
|
1336
|
+
// Trusted Device Token Management (Remember Device Feature)
|
|
1337
|
+
// ============================================================================
|
|
1338
|
+
// NOTE:
|
|
1339
|
+
// - We only create / update trusted device tokens AFTER MFA has been successfully
|
|
1340
|
+
// verified to avoid trusting devices that haven't completed full auth.
|
|
1341
|
+
// - For 'always' mode, this mirrors the behavior in the primary login flow.
|
|
1058
1342
|
let deviceToken = clientInfo.deviceToken;
|
|
1059
1343
|
let isTrustedDevice = false;
|
|
1060
1344
|
if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
|
|
1061
1345
|
const rememberMode = this.config.mfa.rememberDevices;
|
|
1346
|
+
// If a device token is already present, check if it's trusted
|
|
1062
1347
|
if (deviceToken) {
|
|
1063
1348
|
try {
|
|
1064
1349
|
isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
|
|
@@ -1071,6 +1356,7 @@ class AuthService {
|
|
|
1071
1356
|
this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
|
|
1072
1357
|
}
|
|
1073
1358
|
}
|
|
1359
|
+
// Auto-trust mode: create device token automatically if not already trusted
|
|
1074
1360
|
if (rememberMode === 'always' && !isTrustedDevice) {
|
|
1075
1361
|
try {
|
|
1076
1362
|
deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
@@ -1085,14 +1371,18 @@ class AuthService {
|
|
|
1085
1371
|
}
|
|
1086
1372
|
}
|
|
1087
1373
|
}
|
|
1374
|
+
// Check for next challenges (MFA is usually the last challenge)
|
|
1088
1375
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
1089
1376
|
user,
|
|
1090
1377
|
config: this.config,
|
|
1091
1378
|
deviceToken,
|
|
1092
1379
|
isSocialLogin,
|
|
1093
|
-
skipMFAVerification: true,
|
|
1380
|
+
skipMFAVerification: true, // Already verified
|
|
1094
1381
|
authProvider,
|
|
1095
1382
|
});
|
|
1383
|
+
// Propagate trusted device metadata into response so that:
|
|
1384
|
+
// - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
|
|
1385
|
+
// - Mobile clients in JSON mode can store the device token securely
|
|
1096
1386
|
if (isTrustedDevice) {
|
|
1097
1387
|
response.trusted = response.trusted ?? true;
|
|
1098
1388
|
}
|
|
@@ -1104,6 +1394,9 @@ class AuthService {
|
|
|
1104
1394
|
}
|
|
1105
1395
|
else {
|
|
1106
1396
|
this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
|
|
1397
|
+
// ============================================================================
|
|
1398
|
+
// Audit: Record successful login after MFA completion
|
|
1399
|
+
// ============================================================================
|
|
1107
1400
|
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1108
1401
|
if (fireAndForget) {
|
|
1109
1402
|
this.auditService
|
|
@@ -1148,6 +1441,9 @@ class AuthService {
|
|
|
1148
1441
|
}
|
|
1149
1442
|
return response;
|
|
1150
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Handle FORCE_CHANGE_PASSWORD challenge
|
|
1446
|
+
*/
|
|
1151
1447
|
async handleForceChangePassword(challengeSession, newPassword) {
|
|
1152
1448
|
const user = challengeSession.user;
|
|
1153
1449
|
if (!user) {
|
|
@@ -1167,15 +1463,20 @@ class AuthService {
|
|
|
1167
1463
|
description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
|
|
1168
1464
|
},
|
|
1169
1465
|
});
|
|
1466
|
+
// Consume challenge session
|
|
1170
1467
|
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
|
|
1468
|
+
// Reload user from database to get updated mustChangePassword flag
|
|
1171
1469
|
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1172
1470
|
if (!updatedUser) {
|
|
1173
1471
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
|
|
1174
1472
|
}
|
|
1473
|
+
// Get client info
|
|
1175
1474
|
const clientInfo = this.clientInfoService.get();
|
|
1475
|
+
// Read auth context from challenge session metadata
|
|
1176
1476
|
const authMethod = challengeSession.metadata?.authMethod || 'password';
|
|
1177
1477
|
const authProvider = challengeSession.metadata?.authProvider;
|
|
1178
1478
|
const isSocialLogin = authMethod === 'social';
|
|
1479
|
+
// Check for next challenges
|
|
1179
1480
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
1180
1481
|
user: updatedUser,
|
|
1181
1482
|
config: this.config,
|
|
@@ -1189,6 +1490,9 @@ class AuthService {
|
|
|
1189
1490
|
}
|
|
1190
1491
|
else {
|
|
1191
1492
|
this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
|
|
1493
|
+
// ============================================================================
|
|
1494
|
+
// Audit: Record successful login after password change
|
|
1495
|
+
// ============================================================================
|
|
1192
1496
|
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1193
1497
|
if (fireAndForget) {
|
|
1194
1498
|
this.auditService
|
|
@@ -1233,6 +1537,9 @@ class AuthService {
|
|
|
1233
1537
|
}
|
|
1234
1538
|
return response;
|
|
1235
1539
|
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Handle MFA_SETUP_REQUIRED challenge
|
|
1542
|
+
*/
|
|
1236
1543
|
async handleMFASetup(challengeSession, data) {
|
|
1237
1544
|
const user = challengeSession.user;
|
|
1238
1545
|
if (!user) {
|
|
@@ -1242,10 +1549,13 @@ class AuthService {
|
|
|
1242
1549
|
const setupData = data.setupData;
|
|
1243
1550
|
const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
1244
1551
|
this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
|
|
1552
|
+
// Check if MFAService is available
|
|
1245
1553
|
if (!this.mfaService) {
|
|
1246
1554
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
1247
1555
|
}
|
|
1556
|
+
// Get provider
|
|
1248
1557
|
const provider = this.mfaService.getProvider(method);
|
|
1558
|
+
// Verify setup based on method
|
|
1249
1559
|
let deviceId;
|
|
1250
1560
|
try {
|
|
1251
1561
|
deviceId = await provider.verifySetup(user, setupData);
|
|
@@ -1253,30 +1563,41 @@ class AuthService {
|
|
|
1253
1563
|
}
|
|
1254
1564
|
catch (error) {
|
|
1255
1565
|
this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
|
|
1566
|
+
// Increment attempts but don't consume session
|
|
1256
1567
|
await this.challengeService.incrementAttempts(challengeSession);
|
|
1568
|
+
// Re-throw the error
|
|
1257
1569
|
throw error;
|
|
1258
1570
|
}
|
|
1571
|
+
// Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
|
|
1259
1572
|
await this.challengeService.updateMetadata(challengeSession.sessionToken, {
|
|
1260
1573
|
mfaMethod: method,
|
|
1261
1574
|
});
|
|
1575
|
+
// Consume challenge session
|
|
1262
1576
|
await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
|
|
1577
|
+
// Reload user from database to get updated mfaEnabled flag
|
|
1263
1578
|
const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
|
|
1264
1579
|
if (!updatedUser) {
|
|
1265
1580
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
|
|
1266
1581
|
}
|
|
1582
|
+
// Get client info
|
|
1267
1583
|
const clientInfo = this.clientInfoService.get();
|
|
1584
|
+
// Check for next challenges with updated user data
|
|
1585
|
+
// Skip MFA verification because device was already verified during setup
|
|
1268
1586
|
const response = await this.challengeHelper.determineAuthResponse({
|
|
1269
1587
|
user: updatedUser,
|
|
1270
1588
|
config: this.config,
|
|
1271
1589
|
deviceToken: clientInfo.deviceToken,
|
|
1272
1590
|
isSocialLogin: false,
|
|
1273
|
-
skipMFAVerification: true,
|
|
1591
|
+
skipMFAVerification: true, // Device already verified during setup
|
|
1274
1592
|
});
|
|
1275
1593
|
if (response.challengeName) {
|
|
1276
1594
|
this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
|
|
1277
1595
|
}
|
|
1278
1596
|
else {
|
|
1279
1597
|
this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
|
|
1598
|
+
// ============================================================================
|
|
1599
|
+
// Audit: Record successful login after MFA setup
|
|
1600
|
+
// ============================================================================
|
|
1280
1601
|
const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
|
|
1281
1602
|
if (fireAndForget) {
|
|
1282
1603
|
this.auditService
|
|
@@ -1321,15 +1642,42 @@ class AuthService {
|
|
|
1321
1642
|
}
|
|
1322
1643
|
return response;
|
|
1323
1644
|
}
|
|
1645
|
+
// ============================================================================
|
|
1646
|
+
// Challenge Helper Methods
|
|
1647
|
+
// ============================================================================
|
|
1648
|
+
/**
|
|
1649
|
+
* Resend verification code for current challenge
|
|
1650
|
+
*
|
|
1651
|
+
* Determines the challenge type from the session and resends the appropriate code:
|
|
1652
|
+
* - VERIFY_EMAIL: Resends email verification code
|
|
1653
|
+
* - VERIFY_PHONE: Resends SMS verification code
|
|
1654
|
+
* - MFA_REQUIRED: Resends MFA code (for SMS MFA)
|
|
1655
|
+
*
|
|
1656
|
+
* Rate limits are enforced internally by the verification services.
|
|
1657
|
+
*
|
|
1658
|
+
* @param session - Challenge session token
|
|
1659
|
+
* @returns Destination info (masked email/phone)
|
|
1660
|
+
* @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
|
|
1661
|
+
*
|
|
1662
|
+
* @example
|
|
1663
|
+
* ```typescript
|
|
1664
|
+
* const result = await authService.resendCode(session);
|
|
1665
|
+
* // Returns: { destination: 'u***r@example.com' }
|
|
1666
|
+
* ```
|
|
1667
|
+
*/
|
|
1324
1668
|
async resendCode(dto) {
|
|
1325
1669
|
this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
|
|
1670
|
+
// Validate session (session must be valid to resend)
|
|
1326
1671
|
const challengeSession = await this.challengeService.validateSession(dto.session);
|
|
1672
|
+
// Get user from session
|
|
1327
1673
|
const user = challengeSession.user;
|
|
1328
1674
|
if (!user) {
|
|
1329
1675
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
|
|
1330
1676
|
}
|
|
1677
|
+
// Handle based on challenge type
|
|
1331
1678
|
switch (challengeSession.challengeName) {
|
|
1332
1679
|
case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
|
|
1680
|
+
// Resend email verification
|
|
1333
1681
|
const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), { sub: user.sub });
|
|
1334
1682
|
await this.emailVerificationService.resendVerificationEmail(resendDto);
|
|
1335
1683
|
const maskedEmail = this.maskEmail(user.email);
|
|
@@ -1337,12 +1685,14 @@ class AuthService {
|
|
|
1337
1685
|
return { destination: maskedEmail };
|
|
1338
1686
|
}
|
|
1339
1687
|
case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
|
|
1688
|
+
// Check if phone already collected
|
|
1340
1689
|
if (!user.phone) {
|
|
1341
1690
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
|
|
1342
1691
|
}
|
|
1343
1692
|
if (!this.phoneVerificationService) {
|
|
1344
1693
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
|
|
1345
1694
|
}
|
|
1695
|
+
// Resend SMS verification
|
|
1346
1696
|
const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
|
|
1347
1697
|
await this.phoneVerificationService.resendVerificationSMS(resendDto);
|
|
1348
1698
|
const maskedPhone = this.maskPhone(user.phone);
|
|
@@ -1350,33 +1700,41 @@ class AuthService {
|
|
|
1350
1700
|
return { destination: maskedPhone };
|
|
1351
1701
|
}
|
|
1352
1702
|
case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
|
|
1703
|
+
// For MFA, we need to know which method is being used
|
|
1704
|
+
// Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
|
|
1705
|
+
// Note: challengeParameters is never populated - only metadata is used
|
|
1353
1706
|
const metadata = challengeSession.metadata;
|
|
1354
1707
|
const method = metadata?.method;
|
|
1355
1708
|
if (!method) {
|
|
1356
1709
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
|
|
1357
1710
|
}
|
|
1711
|
+
// SMS and Email MFA support resending codes
|
|
1358
1712
|
if (method === 'sms' || method === 'email') {
|
|
1713
|
+
// For SMS, use phone verification service directly to pass challengeSessionId
|
|
1359
1714
|
if (method === 'sms' && this.phoneVerificationService) {
|
|
1360
1715
|
const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
|
|
1361
1716
|
sub: user.sub,
|
|
1362
1717
|
skipAlreadyVerifiedCheck: true,
|
|
1363
|
-
challengeSessionId: challengeSession.id,
|
|
1718
|
+
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
1364
1719
|
});
|
|
1365
1720
|
await this.phoneVerificationService.sendVerificationSMS(smsDto);
|
|
1366
1721
|
this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
|
|
1722
|
+
// Get masked phone from user or device
|
|
1367
1723
|
const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
|
|
1368
1724
|
return { destination: maskedPhone };
|
|
1369
1725
|
}
|
|
1726
|
+
// For Email, use email verification service directly to pass challengeSessionId
|
|
1370
1727
|
if (method === 'email' && this.emailVerificationService) {
|
|
1371
1728
|
const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
|
|
1372
1729
|
sub: user.sub,
|
|
1373
|
-
challengeSessionId: challengeSession.id,
|
|
1730
|
+
challengeSessionId: challengeSession.id, // Link resend code to this challenge session
|
|
1374
1731
|
});
|
|
1375
1732
|
await this.emailVerificationService.resendVerificationEmail(emailDto);
|
|
1376
1733
|
this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
|
|
1377
1734
|
const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
|
|
1378
1735
|
return { destination: maskedEmail };
|
|
1379
1736
|
}
|
|
1737
|
+
// Fallback to provider if services not available (shouldn't happen)
|
|
1380
1738
|
if (!this.mfaService) {
|
|
1381
1739
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
|
|
1382
1740
|
}
|
|
@@ -1386,6 +1744,7 @@ class AuthService {
|
|
|
1386
1744
|
}
|
|
1387
1745
|
const result = await provider.sendChallenge(user);
|
|
1388
1746
|
this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
|
|
1747
|
+
// Provider returns masked phone or email
|
|
1389
1748
|
return { destination: result };
|
|
1390
1749
|
}
|
|
1391
1750
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`);
|
|
@@ -1394,6 +1753,9 @@ class AuthService {
|
|
|
1394
1753
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
|
|
1395
1754
|
}
|
|
1396
1755
|
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Mask email for display (helper method)
|
|
1758
|
+
*/
|
|
1397
1759
|
maskEmail(email) {
|
|
1398
1760
|
const [localPart, domain] = email.split('@');
|
|
1399
1761
|
if (localPart.length <= 2) {
|
|
@@ -1401,11 +1763,30 @@ class AuthService {
|
|
|
1401
1763
|
}
|
|
1402
1764
|
return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
|
|
1403
1765
|
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Mask phone number for display (helper method)
|
|
1768
|
+
*/
|
|
1404
1769
|
maskPhone(phone) {
|
|
1405
1770
|
const digits = phone.replace(/\D/g, '');
|
|
1406
1771
|
const lastFour = digits.slice(-4);
|
|
1407
1772
|
return `***-***-${lastFour}`;
|
|
1408
1773
|
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Registers the current device as trusted for the user (opt-in).
|
|
1776
|
+
*
|
|
1777
|
+
* Only available when rememberDevices is set to 'user_opt_in'. Generates and returns a trusted device token for the device associated with the current authenticated session.
|
|
1778
|
+
*
|
|
1779
|
+
* Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
|
|
1780
|
+
*
|
|
1781
|
+
* @returns Object containing the new device token
|
|
1782
|
+
* @throws {NAuthException} If the feature is unavailable, service is not enabled, or session ID is not available
|
|
1783
|
+
*
|
|
1784
|
+
* @example
|
|
1785
|
+
* ```typescript
|
|
1786
|
+
* const result = await authService.trustDevice();
|
|
1787
|
+
* // { deviceToken: 'abc123' }
|
|
1788
|
+
* ```
|
|
1789
|
+
*/
|
|
1409
1790
|
async trustDevice() {
|
|
1410
1791
|
if (this.config.mfa?.rememberDevices !== 'user_opt_in') {
|
|
1411
1792
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.FORBIDDEN, 'Trust device feature is only available in user_opt_in mode');
|
|
@@ -1413,19 +1794,23 @@ class AuthService {
|
|
|
1413
1794
|
if (!this.trustedDeviceService) {
|
|
1414
1795
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Trusted device service not available');
|
|
1415
1796
|
}
|
|
1797
|
+
// Get sessionId from context (automatically extracted from JWT token)
|
|
1416
1798
|
const clientInfo = this.clientInfoService.get();
|
|
1417
1799
|
const sessionId = clientInfo.sessionId;
|
|
1418
1800
|
if (!sessionId) {
|
|
1419
1801
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session ID not found in request context. Ensure the request is authenticated.');
|
|
1420
1802
|
}
|
|
1803
|
+
// Get session to extract device info
|
|
1421
1804
|
const session = await this.sessionService.findById(sessionId);
|
|
1422
1805
|
if (!session || session.isRevoked) {
|
|
1423
1806
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
1424
1807
|
}
|
|
1808
|
+
// Get user
|
|
1425
1809
|
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
1426
1810
|
if (!user) {
|
|
1427
1811
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1428
1812
|
}
|
|
1813
|
+
// Check if device is already trusted
|
|
1429
1814
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
1430
1815
|
if (clientInfo.deviceToken) {
|
|
1431
1816
|
const isAlreadyTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, userId);
|
|
@@ -1433,24 +1818,33 @@ class AuthService {
|
|
|
1433
1818
|
this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
|
|
1434
1819
|
return { deviceToken: clientInfo.deviceToken };
|
|
1435
1820
|
}
|
|
1821
|
+
// If device token exists but not trusted, revoke it first (may be expired/invalid)
|
|
1436
1822
|
try {
|
|
1437
1823
|
await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, userId);
|
|
1438
1824
|
this.logger?.debug?.(`Revoked existing untrusted device token for user ${user.sub}`);
|
|
1439
1825
|
}
|
|
1440
1826
|
catch {
|
|
1827
|
+
// Non-blocking - may not exist
|
|
1441
1828
|
}
|
|
1442
1829
|
}
|
|
1830
|
+
// Create trusted device token using session device info
|
|
1443
1831
|
const deviceToken = await this.trustedDeviceService.createTrustedDevice(userId, session.deviceName || clientInfo.deviceName, session.deviceType || clientInfo.deviceType, session.ipAddress || clientInfo.ipAddress, session.userAgent || clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
|
|
1444
1832
|
this.logger?.log?.(`Device trusted for user ${user.sub} (user opt-in)`);
|
|
1833
|
+
// ============================================================================
|
|
1834
|
+
// Audit: Record device trust event
|
|
1835
|
+
// ============================================================================
|
|
1445
1836
|
try {
|
|
1837
|
+
// Ensure userId is a number for audit
|
|
1446
1838
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
1447
1839
|
await this.auditService?.recordEvent({
|
|
1448
1840
|
userId,
|
|
1449
1841
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.DEVICE_TRUSTED,
|
|
1450
1842
|
eventStatus: 'SUCCESS',
|
|
1843
|
+
// Override deviceId with the newly created device token
|
|
1451
1844
|
deviceId: deviceToken,
|
|
1452
1845
|
sessionId: session.id,
|
|
1453
1846
|
description: `Device trusted by user (opt-in) - ${session.deviceName || 'Unknown device'}`,
|
|
1847
|
+
// Client info (deviceName, deviceType, etc.) automatically included from context
|
|
1454
1848
|
metadata: {
|
|
1455
1849
|
rememberDeviceDays: this.config.mfa?.rememberDeviceDays || 30,
|
|
1456
1850
|
trustedUntil: new Date(Date.now() + (this.config.mfa?.rememberDeviceDays || 30) * 24 * 60 * 60 * 1000).toISOString(),
|
|
@@ -1458,6 +1852,7 @@ class AuthService {
|
|
|
1458
1852
|
});
|
|
1459
1853
|
}
|
|
1460
1854
|
catch (auditError) {
|
|
1855
|
+
// Non-blocking: Log but continue
|
|
1461
1856
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1462
1857
|
this.logger?.error?.(`Failed to record DEVICE_TRUSTED audit event: ${errorMessage}`, {
|
|
1463
1858
|
error: auditError,
|
|
@@ -1466,23 +1861,49 @@ class AuthService {
|
|
|
1466
1861
|
}
|
|
1467
1862
|
return { deviceToken };
|
|
1468
1863
|
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Check if the current device is trusted
|
|
1866
|
+
*
|
|
1867
|
+
* Returns whether the device associated with the current authenticated session
|
|
1868
|
+
* is trusted. Works for both cookies mode (reads from httpOnly cookie) and
|
|
1869
|
+
* JSON mode (reads from X-Device-Token header).
|
|
1870
|
+
*
|
|
1871
|
+
* This endpoint validates the device token on the server side and checks:
|
|
1872
|
+
* - Device token exists and is valid
|
|
1873
|
+
* - Device token matches a trusted device record in the database
|
|
1874
|
+
* - Trust has not expired
|
|
1875
|
+
*
|
|
1876
|
+
* @returns Object containing the trusted status
|
|
1877
|
+
* @throws {NAuthException} If the session is not found or user is not authenticated
|
|
1878
|
+
*
|
|
1879
|
+
* @example
|
|
1880
|
+
* ```typescript
|
|
1881
|
+
* const result = await authService.isTrustedDevice();
|
|
1882
|
+
* // { trusted: true }
|
|
1883
|
+
* ```
|
|
1884
|
+
*/
|
|
1469
1885
|
async isTrustedDevice() {
|
|
1470
1886
|
if (!this.trustedDeviceService) {
|
|
1887
|
+
// If trusted device service is not available, device is not trusted
|
|
1471
1888
|
return { trusted: false };
|
|
1472
1889
|
}
|
|
1890
|
+
// Get sessionId from context (automatically extracted from JWT token)
|
|
1473
1891
|
const clientInfo = this.clientInfoService.get();
|
|
1474
1892
|
const sessionId = clientInfo.sessionId;
|
|
1475
1893
|
if (!sessionId) {
|
|
1476
1894
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session ID not found in request context. Ensure the request is authenticated.');
|
|
1477
1895
|
}
|
|
1896
|
+
// Get session to extract user
|
|
1478
1897
|
const session = await this.sessionService.findById(sessionId);
|
|
1479
1898
|
if (!session || session.isRevoked) {
|
|
1480
1899
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
1481
1900
|
}
|
|
1901
|
+
// Get user
|
|
1482
1902
|
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
1483
1903
|
if (!user) {
|
|
1484
1904
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1485
1905
|
}
|
|
1906
|
+
// Check if device is trusted
|
|
1486
1907
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
1487
1908
|
const deviceToken = clientInfo.deviceToken;
|
|
1488
1909
|
if (!deviceToken) {
|
|
@@ -1491,15 +1912,40 @@ class AuthService {
|
|
|
1491
1912
|
const isTrusted = await this.trustedDeviceService.isDeviceTrusted(deviceToken, userId);
|
|
1492
1913
|
return { trusted: isTrusted };
|
|
1493
1914
|
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Refresh the access token using a refresh token.
|
|
1917
|
+
*
|
|
1918
|
+
* Handles secure token rotation with distributed locking, reuse detection,
|
|
1919
|
+
* and family revocation to prevent race conditions and replay attacks.
|
|
1920
|
+
*
|
|
1921
|
+
* @param refreshToken - The refresh token issued to the client
|
|
1922
|
+
* @returns Newly generated access and refresh tokens
|
|
1923
|
+
* @throws {NAuthException} If the session is not found, revoked, or refresh is abused
|
|
1924
|
+
*
|
|
1925
|
+
* @example
|
|
1926
|
+
* ```typescript
|
|
1927
|
+
* const tokens = await authService.refreshToken(refreshToken);
|
|
1928
|
+
* ```
|
|
1929
|
+
*/
|
|
1494
1930
|
async refreshToken(dto) {
|
|
1495
1931
|
const tokenHash = this.jwtService.hashToken(dto.refreshToken);
|
|
1932
|
+
// ============================================================================
|
|
1933
|
+
// CRITICAL SECURITY FIX #1 & #2: Distributed Lock + Reuse Detection
|
|
1934
|
+
// ============================================================================
|
|
1935
|
+
// CRITICAL: We need to get session ID for locking, but we must lock BEFORE validation
|
|
1936
|
+
// to prevent race conditions. So we do a quick, lightweight lookup first.
|
|
1937
|
+
// Find session by refresh token hash - this is fast and allows us to get session ID
|
|
1496
1938
|
const session = await this.sessionService.findByRefreshToken(tokenHash);
|
|
1497
1939
|
if (!session || session.isRevoked) {
|
|
1940
|
+
// Validate token to get user info for error message
|
|
1498
1941
|
const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
|
|
1499
1942
|
const userId = validation.payload?.sub || 'unknown';
|
|
1500
1943
|
this.logger?.debug?.(`Session not found or revoked for user ${userId}. Possible issue where token are not cleared on logout`);
|
|
1501
1944
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
1502
1945
|
}
|
|
1946
|
+
// Acquire distributed lock using SESSION ID (not token hash)
|
|
1947
|
+
// THIS MUST HAPPEN BEFORE VALIDATION to prevent race conditions
|
|
1948
|
+
// where multiple requests validate the same token before any lock is acquired
|
|
1503
1949
|
const lockKey = `session-refresh:${session.id}`;
|
|
1504
1950
|
this.logger?.debug?.(`[REFRESH DEBUG] Attempting to acquire lock ${lockKey} for token hash ${tokenHash.substring(0, 16)}...`);
|
|
1505
1951
|
let lockAcquired = false;
|
|
@@ -1514,32 +1960,50 @@ class AuthService {
|
|
|
1514
1960
|
});
|
|
1515
1961
|
}
|
|
1516
1962
|
this.logger?.debug?.(`[REFRESH DEBUG] Lock ${lockKey} acquired successfully in ${lockDuration}ms for token hash ${tokenHash.substring(0, 16)}...`);
|
|
1963
|
+
// CRITICAL: Check for token reuse IMMEDIATELY after acquiring lock
|
|
1964
|
+
// If same session + cookie race → return current tokens (don't reissue)
|
|
1965
|
+
// If different session → invalidate that session and reject (attack)
|
|
1517
1966
|
if (this.config.jwt.refreshToken.reuseDetection) {
|
|
1518
1967
|
const isAlreadyUsed = await this.sessionService.isRefreshTokenUsed(tokenHash);
|
|
1519
1968
|
if (isAlreadyUsed) {
|
|
1969
|
+
// Decode token to get sessionId from JWT payload (without full validation)
|
|
1970
|
+
// This allows us to check if the token belongs to the session we found
|
|
1520
1971
|
const tokenPayload = this.jwtService.decodeToken(dto.refreshToken);
|
|
1521
1972
|
const tokenSessionId = tokenPayload?.sessionId;
|
|
1973
|
+
// Get current session state to ensure it's still valid
|
|
1522
1974
|
const currentSession = (await this.sessionService.findByIdLight(session.id));
|
|
1523
1975
|
if (!currentSession || currentSession.isRevoked) {
|
|
1524
1976
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
1525
1977
|
}
|
|
1978
|
+
// Check if token's sessionId matches the session we found
|
|
1979
|
+
// If they match → cookie race (same session)
|
|
1980
|
+
// If they don't match → attack (token stolen from different session)
|
|
1526
1981
|
if (tokenSessionId && tokenSessionId === session.id.toString()) {
|
|
1982
|
+
// Same session - this is a cookie race condition
|
|
1983
|
+
// Return the current valid tokens (user already has them from first request)
|
|
1527
1984
|
this.logger?.debug?.(`[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for same session ${session.id} - cookie race detected, returning current tokens`);
|
|
1985
|
+
// Get user info
|
|
1528
1986
|
const user = (await this.userRepository.findOne({
|
|
1529
1987
|
where: { id: currentSession.userId },
|
|
1530
1988
|
}));
|
|
1531
1989
|
if (!user) {
|
|
1532
1990
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1533
1991
|
}
|
|
1992
|
+
// Generate tokens from current session state (same as what the first request returned)
|
|
1993
|
+
// These will match what the user already has, so no change needed
|
|
1994
|
+
// Note: deviceId not included in token - session.deviceId is source of truth
|
|
1534
1995
|
const newTokens = await this.jwtService.generateTokenPair({
|
|
1535
1996
|
userId: user.sub,
|
|
1536
1997
|
email: user.email,
|
|
1537
1998
|
sessionId: currentSession.id.toString(),
|
|
1538
1999
|
tokenFamily: currentSession.tokenFamily,
|
|
1539
2000
|
});
|
|
2001
|
+
// Update session with these tokens (they're already there, but ensures consistency)
|
|
1540
2002
|
await this.sessionService.updateTokens(currentSession.id, this.jwtService.hashToken(newTokens.accessToken), this.jwtService.hashToken(newTokens.refreshToken));
|
|
2003
|
+
// Decode tokens to get expiry times
|
|
1541
2004
|
const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
|
|
1542
2005
|
const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
|
|
2006
|
+
// Return success with current tokens
|
|
1543
2007
|
return {
|
|
1544
2008
|
accessToken: newTokens.accessToken,
|
|
1545
2009
|
refreshToken: newTokens.refreshToken,
|
|
@@ -1548,8 +2012,12 @@ class AuthService {
|
|
|
1548
2012
|
};
|
|
1549
2013
|
}
|
|
1550
2014
|
else {
|
|
2015
|
+
// Different session - this is an attack!
|
|
2016
|
+
// A refresh token from one session cannot be used by another session
|
|
1551
2017
|
this.logger?.error?.(`[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for different session - ATTACK DETECTED! Token sessionId: ${tokenSessionId}, Found session: ${session.id}. Revoking session ${session.id}`);
|
|
2018
|
+
// Revoke the session that's trying to use a stolen token
|
|
1552
2019
|
await this.sessionService.revokeSession(session.id, 'Token reuse detected - possible token theft');
|
|
2020
|
+
// Audit the attack
|
|
1553
2021
|
let userForAudit = null;
|
|
1554
2022
|
try {
|
|
1555
2023
|
userForAudit = (await this.userRepository.findOne({
|
|
@@ -1563,6 +2031,7 @@ class AuthService {
|
|
|
1563
2031
|
riskFactor: 90,
|
|
1564
2032
|
riskFactors: [risk_factor_enum_1.RiskFactor.TOKEN_THEFT_ATTEMPT, risk_factor_enum_1.RiskFactor.REFRESH_TOKEN_REUSE_DIFFERENT_SESSION],
|
|
1565
2033
|
reason: 'Refresh token reuse from different session',
|
|
2034
|
+
// Client info automatically included from context
|
|
1566
2035
|
description: 'Refresh token from another session attempted to be used. Session revoked as security measure.',
|
|
1567
2036
|
metadata: {
|
|
1568
2037
|
sessionId: session.id,
|
|
@@ -1585,21 +2054,34 @@ class AuthService {
|
|
|
1585
2054
|
}
|
|
1586
2055
|
}
|
|
1587
2056
|
}
|
|
2057
|
+
// NOW validate the refresh token (after lock is acquired and reuse check)
|
|
2058
|
+
// This ensures only one request can validate at a time per session
|
|
1588
2059
|
const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
|
|
1589
2060
|
if (!validation.valid || !validation.payload) {
|
|
1590
2061
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.TOKEN_INVALID, 'Invalid refresh token');
|
|
1591
2062
|
}
|
|
1592
2063
|
const payload = validation.payload;
|
|
2064
|
+
// Re-check session after acquiring lock (it might have been revoked/updated)
|
|
2065
|
+
// Since we have the lock, no other request can modify this session, but it might have been revoked
|
|
2066
|
+
// We already have currentSession from the early reuse check, but re-fetch to ensure it's still valid
|
|
1593
2067
|
const lockedSession = (await this.sessionService.findByIdLight(session.id));
|
|
1594
2068
|
if (!lockedSession || lockedSession.isRevoked || lockedSession.id !== session.id) {
|
|
1595
2069
|
this.logger?.debug?.(`Session changed after lock acquisition for user ${payload.sub}. Session may have been revoked.`);
|
|
1596
2070
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
|
|
1597
2071
|
}
|
|
2072
|
+
// ============================================================================
|
|
2073
|
+
// NOTE: We still do the atomic mark operation below as a double-check
|
|
2074
|
+
// The early check above handles cookie race conditions where old tokens
|
|
2075
|
+
// are sent before new cookies are received
|
|
2076
|
+
// ============================================================================
|
|
2077
|
+
// Mark token as used BEFORE generating new tokens (prevents reuse)
|
|
1598
2078
|
if (this.config.jwt.refreshToken.reuseDetection) {
|
|
1599
2079
|
const refreshTokenTTL = this.jwtService.getRefreshTokenTTL();
|
|
1600
2080
|
const marked = await this.sessionService.markRefreshTokenAsUsed(tokenHash, refreshTokenTTL);
|
|
1601
2081
|
if (!marked) {
|
|
2082
|
+
// Token was already marked as used - reuse detected!
|
|
1602
2083
|
this.logger?.error?.(`Token reuse detected for user ${payload.sub} - atomic mark failed, revoking entire token family ${payload.tokenFamily}`);
|
|
2084
|
+
// Audit the reuse attempt
|
|
1603
2085
|
try {
|
|
1604
2086
|
const userForAudit = (await this.userRepository.findOne({
|
|
1605
2087
|
where: { sub: payload.sub },
|
|
@@ -1612,6 +2094,7 @@ class AuthService {
|
|
|
1612
2094
|
riskFactor: 75,
|
|
1613
2095
|
riskFactors: [risk_factor_enum_1.RiskFactor.TOKEN_REUSE_ATTEMPT],
|
|
1614
2096
|
reason: 'Token reuse attempt blocked',
|
|
2097
|
+
// Client info automatically included from context
|
|
1615
2098
|
description: 'Refresh token reuse attempt detected via atomic operation. Legitimate user session preserved.',
|
|
1616
2099
|
metadata: {
|
|
1617
2100
|
tokenFamily: payload.tokenFamily,
|
|
@@ -1628,14 +2111,19 @@ class AuthService {
|
|
|
1628
2111
|
}
|
|
1629
2112
|
this.logger?.debug?.(`Marked refresh token as used for session ${lockedSession.id}`);
|
|
1630
2113
|
}
|
|
2114
|
+
// Generate new token pair with same family
|
|
2115
|
+
// Note: deviceId not included in token - session.deviceId is source of truth
|
|
1631
2116
|
const newTokens = await this.jwtService.generateTokenPair({
|
|
1632
2117
|
userId: payload.sub,
|
|
1633
2118
|
email: payload.email,
|
|
1634
|
-
sessionId: lockedSession.id.toString(),
|
|
2119
|
+
sessionId: lockedSession.id.toString(), // Convert integer to string for JWT
|
|
1635
2120
|
tokenFamily: payload.tokenFamily,
|
|
1636
2121
|
});
|
|
2122
|
+
// Update session with new token hashes (token rotation)
|
|
2123
|
+
// This automatically invalidates the old tokens as they won't match the session
|
|
1637
2124
|
await this.sessionService.updateTokens(lockedSession.id, this.jwtService.hashToken(newTokens.accessToken), this.jwtService.hashToken(newTokens.refreshToken));
|
|
1638
2125
|
this.logger?.log?.(`Token refreshed successfully for user ${payload.sub}`);
|
|
2126
|
+
// Decode new tokens to get expiry times
|
|
1639
2127
|
const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
|
|
1640
2128
|
const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
|
|
1641
2129
|
return {
|
|
@@ -1646,22 +2134,40 @@ class AuthService {
|
|
|
1646
2134
|
};
|
|
1647
2135
|
}
|
|
1648
2136
|
finally {
|
|
2137
|
+
// Always release lock, even if error occurs
|
|
2138
|
+
// Only release if we successfully acquired it
|
|
1649
2139
|
if (lockAcquired) {
|
|
1650
2140
|
await this.sessionService.releaseRefreshLock(lockKey);
|
|
1651
2141
|
this.logger?.debug?.(`[REFRESH DEBUG] Released lock ${lockKey}`);
|
|
1652
2142
|
}
|
|
1653
2143
|
}
|
|
1654
2144
|
}
|
|
2145
|
+
// ============================================================================
|
|
2146
|
+
// Logout
|
|
2147
|
+
// ============================================================================
|
|
2148
|
+
/**
|
|
2149
|
+
* Logout user (revoke session)
|
|
2150
|
+
*
|
|
2151
|
+
* Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
|
|
2152
|
+
*
|
|
2153
|
+
* @param dto - Logout options (forgetMe flag)
|
|
2154
|
+
* @returns Success status
|
|
2155
|
+
* @throws {NAuthException} If session ID is not available in request context
|
|
2156
|
+
*/
|
|
1655
2157
|
async logout(dto) {
|
|
2158
|
+
// Get sessionId from context (automatically extracted from JWT token)
|
|
1656
2159
|
const clientInfo = this.clientInfoService.get();
|
|
1657
2160
|
let sessionId = clientInfo.sessionId;
|
|
2161
|
+
// Fallback: Try to get sessionId from JWT payload in context
|
|
1658
2162
|
if (!sessionId) {
|
|
1659
2163
|
const jwtPayload = context_storage_1.ContextStorage.get('JWT_PAYLOAD');
|
|
1660
2164
|
if (jwtPayload?.sessionId) {
|
|
2165
|
+
// Parse sessionId to number (JWT payload has it as string)
|
|
1661
2166
|
const sessionIdStr = String(jwtPayload.sessionId);
|
|
1662
2167
|
const sessionIdNumber = parseInt(sessionIdStr, 10);
|
|
1663
2168
|
if (!isNaN(sessionIdNumber) && sessionIdNumber > 0) {
|
|
1664
2169
|
sessionId = sessionIdNumber;
|
|
2170
|
+
// Update CLIENT_INFO in context for future use
|
|
1665
2171
|
const clientInfoInContext = context_storage_1.ContextStorage.get('CLIENT_INFO');
|
|
1666
2172
|
if (clientInfoInContext) {
|
|
1667
2173
|
clientInfoInContext.sessionId = sessionIdNumber;
|
|
@@ -1673,6 +2179,7 @@ class AuthService {
|
|
|
1673
2179
|
if (!sessionId) {
|
|
1674
2180
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session ID not found in request context. Ensure the request is authenticated.');
|
|
1675
2181
|
}
|
|
2182
|
+
// Prepare metadata for audit trail
|
|
1676
2183
|
const auditMetadata = dto.forgetMe
|
|
1677
2184
|
? {
|
|
1678
2185
|
deviceForgotten: true,
|
|
@@ -1680,19 +2187,26 @@ class AuthService {
|
|
|
1680
2187
|
}
|
|
1681
2188
|
: undefined;
|
|
1682
2189
|
await this.sessionService.revokeSession(sessionId, 'User logout', auditMetadata);
|
|
2190
|
+
// If forgetMe is true, revoke trusted device
|
|
1683
2191
|
if (dto.forgetMe &&
|
|
1684
2192
|
this.config.mfa?.rememberDevices &&
|
|
1685
2193
|
this.config.mfa?.rememberDevices !== 'never' &&
|
|
1686
2194
|
this.trustedDeviceService) {
|
|
1687
2195
|
if (clientInfo.deviceToken) {
|
|
1688
2196
|
try {
|
|
2197
|
+
// Get session to get userId
|
|
1689
2198
|
const session = await this.sessionService.findById(sessionId);
|
|
1690
2199
|
if (session) {
|
|
1691
2200
|
await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, session.userId);
|
|
1692
2201
|
this.logger?.log?.(`Revoked trusted device token for user (forgetMe=true)`);
|
|
2202
|
+
// Get user for audit
|
|
1693
2203
|
const user = await this.userRepository.findOne({ where: { id: session.userId } });
|
|
1694
2204
|
if (user) {
|
|
2205
|
+
// ============================================================================
|
|
2206
|
+
// Audit: Record device untrust event
|
|
2207
|
+
// ============================================================================
|
|
1695
2208
|
try {
|
|
2209
|
+
// Ensure userId is a number for audit
|
|
1696
2210
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
1697
2211
|
await this.auditService?.recordEvent({
|
|
1698
2212
|
userId,
|
|
@@ -1700,12 +2214,14 @@ class AuthService {
|
|
|
1700
2214
|
eventStatus: 'SUCCESS',
|
|
1701
2215
|
sessionId: session.id,
|
|
1702
2216
|
description: `Device untrusted by user (forgetMe=true) - ${session.deviceName || 'Unknown device'}`,
|
|
2217
|
+
// Client info (deviceId, deviceName, deviceType, etc.) automatically included from context
|
|
1703
2218
|
metadata: {
|
|
1704
2219
|
reason: 'user_logout_forget_me',
|
|
1705
2220
|
},
|
|
1706
2221
|
});
|
|
1707
2222
|
}
|
|
1708
2223
|
catch (auditError) {
|
|
2224
|
+
// Non-blocking: Log but continue
|
|
1709
2225
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1710
2226
|
this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
|
|
1711
2227
|
error: auditError,
|
|
@@ -1716,11 +2232,15 @@ class AuthService {
|
|
|
1716
2232
|
}
|
|
1717
2233
|
}
|
|
1718
2234
|
catch (error) {
|
|
2235
|
+
// Non-blocking: Log but continue
|
|
1719
2236
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1720
2237
|
this.logger?.debug?.(`Failed to revoke trusted device token on logout: ${errorMessage}`, { error });
|
|
1721
2238
|
}
|
|
1722
2239
|
}
|
|
1723
2240
|
}
|
|
2241
|
+
// ============================================================================
|
|
2242
|
+
// Automatically Clear Auth Cookies (if using cookie-based token delivery)
|
|
2243
|
+
// ============================================================================
|
|
1724
2244
|
const response = this.clientInfoService.getResponse();
|
|
1725
2245
|
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
1726
2246
|
this.clearAuthCookies(response, dto.forgetMe ?? false);
|
|
@@ -1728,30 +2248,49 @@ class AuthService {
|
|
|
1728
2248
|
}
|
|
1729
2249
|
return { success: true };
|
|
1730
2250
|
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Clear authentication cookies from response
|
|
2253
|
+
*
|
|
2254
|
+
* @param response - HTTP response object with clearCookie method
|
|
2255
|
+
* @param forgetDevice - Whether to also clear device token cookie
|
|
2256
|
+
* @private
|
|
2257
|
+
*/
|
|
1731
2258
|
clearAuthCookies(response, forgetDevice) {
|
|
1732
2259
|
if (!response.clearCookie) {
|
|
1733
|
-
return;
|
|
2260
|
+
return; // Response doesn't support cookie clearing (shouldn't happen)
|
|
1734
2261
|
}
|
|
1735
2262
|
const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
|
|
1736
2263
|
const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
|
|
2264
|
+
// Clear access and refresh tokens
|
|
1737
2265
|
response.clearCookie(`${prefix}_access_token`, cookieOptions);
|
|
1738
2266
|
response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
|
|
2267
|
+
// Clear CSRF token cookie (httpOnly: false, so it can be cleared)
|
|
2268
|
+
// Use the same cookie options but with httpOnly: false to match how it was set
|
|
1739
2269
|
const csrfCookieOptions = {
|
|
1740
2270
|
...cookieOptions,
|
|
1741
|
-
httpOnly: false,
|
|
2271
|
+
httpOnly: false, // CSRF token cookie is not httpOnly
|
|
1742
2272
|
};
|
|
1743
2273
|
const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
|
|
1744
2274
|
response.clearCookie(csrfCookieName, csrfCookieOptions);
|
|
2275
|
+
// Clear device token if forgetting device
|
|
1745
2276
|
if (forgetDevice) {
|
|
1746
2277
|
response.clearCookie(`${prefix}_device_token`, cookieOptions);
|
|
1747
2278
|
}
|
|
1748
2279
|
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Global signout (revoke all user sessions)
|
|
2282
|
+
* @param sub - External user identifier (sub/UUID)
|
|
2283
|
+
* @returns Number of sessions revoked
|
|
2284
|
+
*/
|
|
1749
2285
|
async logoutAll(dto) {
|
|
2286
|
+
// Get user by sub to get internal id
|
|
1750
2287
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
1751
2288
|
if (!user) {
|
|
1752
2289
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1753
2290
|
}
|
|
2291
|
+
// Use internal id for session queries
|
|
1754
2292
|
const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Global signout');
|
|
2293
|
+
// Revoke all trusted devices if forgetDevices flag is set
|
|
1755
2294
|
let revokedDevicesCount = 0;
|
|
1756
2295
|
let revokedDevices = [];
|
|
1757
2296
|
if (dto.forgetDevices &&
|
|
@@ -1763,6 +2302,7 @@ class AuthService {
|
|
|
1763
2302
|
revokedDevicesCount = deviceRevocationResult.revokedCount;
|
|
1764
2303
|
revokedDevices = deviceRevocationResult.devices;
|
|
1765
2304
|
this.logger?.log?.(`Revoked ${revokedDevicesCount} trusted device(s) for user ${user.sub} (forgetDevices=true)`);
|
|
2305
|
+
// Record audit event for device revocation
|
|
1766
2306
|
if (revokedDevicesCount > 0 && this.auditService) {
|
|
1767
2307
|
try {
|
|
1768
2308
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
@@ -1784,6 +2324,7 @@ class AuthService {
|
|
|
1784
2324
|
});
|
|
1785
2325
|
}
|
|
1786
2326
|
catch (auditError) {
|
|
2327
|
+
// Non-blocking: Log but continue
|
|
1787
2328
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1788
2329
|
this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
|
|
1789
2330
|
error: auditError,
|
|
@@ -1793,10 +2334,14 @@ class AuthService {
|
|
|
1793
2334
|
}
|
|
1794
2335
|
}
|
|
1795
2336
|
catch (error) {
|
|
2337
|
+
// Non-blocking: Log but continue
|
|
1796
2338
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1797
2339
|
this.logger?.debug?.(`Failed to revoke trusted devices on global logout: ${errorMessage}`, { error });
|
|
1798
2340
|
}
|
|
1799
2341
|
}
|
|
2342
|
+
// ============================================================================
|
|
2343
|
+
// Audit: Record GLOBAL_SIGNOUT event (individual SESSION_REVOKED events recorded in SessionService)
|
|
2344
|
+
// ============================================================================
|
|
1800
2345
|
if (this.auditService && revokedCount > 0) {
|
|
1801
2346
|
try {
|
|
1802
2347
|
const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
|
|
@@ -1827,6 +2372,7 @@ class AuthService {
|
|
|
1827
2372
|
});
|
|
1828
2373
|
}
|
|
1829
2374
|
catch (auditError) {
|
|
2375
|
+
// Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
|
|
1830
2376
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
1831
2377
|
this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
|
|
1832
2378
|
error: auditError,
|
|
@@ -1834,28 +2380,60 @@ class AuthService {
|
|
|
1834
2380
|
});
|
|
1835
2381
|
}
|
|
1836
2382
|
}
|
|
2383
|
+
// ============================================================================
|
|
2384
|
+
// Automatically Clear Auth Cookies (if using cookie-based token delivery)
|
|
2385
|
+
// ============================================================================
|
|
1837
2386
|
const response = this.clientInfoService.getResponse();
|
|
1838
2387
|
if (response && this.config.tokenDelivery?.method !== 'json') {
|
|
2388
|
+
// Clear auth cookies
|
|
2389
|
+
// If forgetDevices is true, also clear device token cookie
|
|
1839
2390
|
this.clearAuthCookies(response, dto.forgetDevices ?? false);
|
|
1840
2391
|
this.logger?.debug?.('Auth cookies cleared automatically on global logout');
|
|
1841
2392
|
}
|
|
1842
2393
|
return { revokedCount };
|
|
1843
2394
|
}
|
|
2395
|
+
// ============================================================================
|
|
2396
|
+
// Password Management
|
|
2397
|
+
// ============================================================================
|
|
2398
|
+
/**
|
|
2399
|
+
* Change the password for an existing user.
|
|
2400
|
+
*
|
|
2401
|
+
* Verifies the current password, validates the new password,
|
|
2402
|
+
* checks password reuse policy, and updates the user's password hash and history.
|
|
2403
|
+
* Executes configured pre-change hooks if provided.
|
|
2404
|
+
*
|
|
2405
|
+
* @param sub - External user identifier (sub/UUID)
|
|
2406
|
+
* @param dto - ChangePasswordDTO containing old and new password
|
|
2407
|
+
* @returns void
|
|
2408
|
+
* @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
|
|
2409
|
+
*
|
|
2410
|
+
* @example
|
|
2411
|
+
* ```typescript
|
|
2412
|
+
* await authService.changePassword('user-uuid', {
|
|
2413
|
+
* oldPassword: 'currentPass123!',
|
|
2414
|
+
* newPassword: 'newStr0ngPass!@#',
|
|
2415
|
+
* });
|
|
2416
|
+
* ```
|
|
2417
|
+
*/
|
|
1844
2418
|
async changePassword(dto) {
|
|
2419
|
+
// Get user by sub
|
|
1845
2420
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
1846
2421
|
if (!user || !user.passwordHash) {
|
|
1847
2422
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1848
2423
|
}
|
|
2424
|
+
// Execute beforePasswordChange hook (use sub for external API)
|
|
1849
2425
|
if (this.config.hooks?.beforePasswordChange) {
|
|
1850
2426
|
const result = await this.config.hooks.beforePasswordChange(dto.sub, dto.oldPassword);
|
|
1851
2427
|
if (result === false) {
|
|
1852
2428
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
|
|
1853
2429
|
}
|
|
1854
2430
|
}
|
|
2431
|
+
// Verify old password
|
|
1855
2432
|
const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
|
|
1856
2433
|
if (!isValid) {
|
|
1857
2434
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
|
|
1858
2435
|
}
|
|
2436
|
+
// Execute afterPasswordChange hook (use sub for external API)
|
|
1859
2437
|
if (this.config.hooks?.afterPasswordChange) {
|
|
1860
2438
|
await this.config.hooks.afterPasswordChange(dto.sub);
|
|
1861
2439
|
}
|
|
@@ -1872,13 +2450,30 @@ class AuthService {
|
|
|
1872
2450
|
});
|
|
1873
2451
|
return { success: true };
|
|
1874
2452
|
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Update user profile attributes.
|
|
2455
|
+
*
|
|
2456
|
+
* Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
|
|
2457
|
+
*
|
|
2458
|
+
* @param sub - User sub/UUID
|
|
2459
|
+
* @param updateData - User fields to update
|
|
2460
|
+
* @returns Updated user object
|
|
2461
|
+
* @throws {NAuthException} If user not found or unique constraint violated
|
|
2462
|
+
*
|
|
2463
|
+
* @example
|
|
2464
|
+
* await authService.updateUserAttributes(sub, { email: 'test@example.com' });
|
|
2465
|
+
*/
|
|
1875
2466
|
async updateUserAttributes(dto) {
|
|
2467
|
+
// Find user by sub (external identifier)
|
|
1876
2468
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
1877
2469
|
if (!user) {
|
|
1878
2470
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
1879
2471
|
}
|
|
2472
|
+
// Check for uniqueness constraints - use internal id
|
|
1880
2473
|
await this.validateUniquenessConstraints(user.id, dto);
|
|
2474
|
+
// Prepare update object
|
|
1881
2475
|
const updateFields = {};
|
|
2476
|
+
// Update basic fields if provided
|
|
1882
2477
|
if (dto.firstName !== undefined) {
|
|
1883
2478
|
updateFields.firstName = dto.firstName;
|
|
1884
2479
|
}
|
|
@@ -1891,15 +2486,25 @@ class AuthService {
|
|
|
1891
2486
|
if (dto.email !== undefined) {
|
|
1892
2487
|
const oldEmail = user.email;
|
|
1893
2488
|
updateFields.email = dto.email;
|
|
2489
|
+
// Reset email verification if email changed (unless retainVerification is true)
|
|
1894
2490
|
if (dto.email !== user.email) {
|
|
1895
2491
|
if (!dto.retainVerification) {
|
|
1896
2492
|
updateFields.isEmailVerified = false;
|
|
1897
2493
|
}
|
|
1898
2494
|
else {
|
|
2495
|
+
// Explicitly retain current verification status
|
|
1899
2496
|
updateFields.isEmailVerified = user.isEmailVerified;
|
|
1900
2497
|
}
|
|
2498
|
+
// ============================================================================
|
|
2499
|
+
// MFA Device Management: Handle Email MFA devices when email changes
|
|
2500
|
+
// ============================================================================
|
|
2501
|
+
// When email address changes, Email MFA devices become invalid.
|
|
2502
|
+
// We deactivate them and check if user has any other active MFA devices.
|
|
2503
|
+
// If Email was the only MFA method, user will need to set up MFA again.
|
|
2504
|
+
// This happens automatically via challenge system at next login.
|
|
1901
2505
|
if (oldEmail && this.mfaDeviceRepository) {
|
|
1902
2506
|
try {
|
|
2507
|
+
// Find all Email MFA devices (email field may be null in legacy devices)
|
|
1903
2508
|
const emailDevices = (await this.mfaDeviceRepository.find({
|
|
1904
2509
|
where: {
|
|
1905
2510
|
userId: user.id,
|
|
@@ -1909,10 +2514,12 @@ class AuthService {
|
|
|
1909
2514
|
}));
|
|
1910
2515
|
if (emailDevices.length > 0) {
|
|
1911
2516
|
this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
|
|
2517
|
+
// Delete all Email devices (can't be reactivated with old email)
|
|
1912
2518
|
for (const device of emailDevices) {
|
|
1913
2519
|
const deviceId = device.id;
|
|
1914
2520
|
await this.mfaDeviceRepository.delete(deviceId);
|
|
1915
2521
|
}
|
|
2522
|
+
// Record audit event for removed Email MFA devices
|
|
1916
2523
|
if (this.auditService) {
|
|
1917
2524
|
try {
|
|
1918
2525
|
await this.auditService.recordEvent({
|
|
@@ -1935,12 +2542,14 @@ class AuthService {
|
|
|
1935
2542
|
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
1936
2543
|
}
|
|
1937
2544
|
}
|
|
2545
|
+
// Check if user has any other active MFA devices
|
|
1938
2546
|
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
1939
2547
|
where: {
|
|
1940
2548
|
userId: user.id,
|
|
1941
2549
|
isActive: true,
|
|
1942
2550
|
},
|
|
1943
2551
|
}));
|
|
2552
|
+
// If no active devices remain and user had MFA enabled, disable MFA
|
|
1944
2553
|
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
1945
2554
|
updateFields.mfaEnabled = false;
|
|
1946
2555
|
updateFields.mfaMethods = [];
|
|
@@ -1953,6 +2562,8 @@ class AuthService {
|
|
|
1953
2562
|
}
|
|
1954
2563
|
}
|
|
1955
2564
|
catch (error) {
|
|
2565
|
+
// Log error but don't fail the email update
|
|
2566
|
+
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
1956
2567
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1957
2568
|
this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
|
|
1958
2569
|
}
|
|
@@ -1962,15 +2573,25 @@ class AuthService {
|
|
|
1962
2573
|
if (dto.phone !== undefined) {
|
|
1963
2574
|
const oldPhone = user.phone;
|
|
1964
2575
|
updateFields.phone = dto.phone;
|
|
2576
|
+
// Reset phone verification if phone changed (unless retainVerification is true)
|
|
1965
2577
|
if (dto.phone !== user.phone) {
|
|
1966
2578
|
if (!dto.retainVerification) {
|
|
1967
2579
|
updateFields.isPhoneVerified = false;
|
|
1968
2580
|
}
|
|
1969
2581
|
else {
|
|
2582
|
+
// Explicitly retain current verification status
|
|
1970
2583
|
updateFields.isPhoneVerified = user.isPhoneVerified;
|
|
1971
2584
|
}
|
|
2585
|
+
// ============================================================================
|
|
2586
|
+
// MFA Device Management: Handle SMS MFA devices when phone changes
|
|
2587
|
+
// ============================================================================
|
|
2588
|
+
// When phone number changes, SMS MFA devices become invalid.
|
|
2589
|
+
// We delete them and check if user has any other active MFA devices.
|
|
2590
|
+
// If SMS was the only MFA method, user will need to set up MFA again.
|
|
2591
|
+
// This happens automatically via challenge system at next login.
|
|
1972
2592
|
if (oldPhone && this.mfaDeviceRepository) {
|
|
1973
2593
|
try {
|
|
2594
|
+
// Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
|
|
1974
2595
|
const smsDevices = (await this.mfaDeviceRepository.find({
|
|
1975
2596
|
where: {
|
|
1976
2597
|
userId: user.id,
|
|
@@ -1980,10 +2601,12 @@ class AuthService {
|
|
|
1980
2601
|
}));
|
|
1981
2602
|
if (smsDevices.length > 0) {
|
|
1982
2603
|
this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
|
|
2604
|
+
// Delete all SMS devices (can't be reactivated with old phone number)
|
|
1983
2605
|
for (const device of smsDevices) {
|
|
1984
2606
|
const deviceId = device.id;
|
|
1985
2607
|
await this.mfaDeviceRepository.delete(deviceId);
|
|
1986
2608
|
}
|
|
2609
|
+
// Record audit event for removed SMS MFA devices
|
|
1987
2610
|
if (this.auditService) {
|
|
1988
2611
|
try {
|
|
1989
2612
|
await this.auditService.recordEvent({
|
|
@@ -2006,12 +2629,14 @@ class AuthService {
|
|
|
2006
2629
|
this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
|
|
2007
2630
|
}
|
|
2008
2631
|
}
|
|
2632
|
+
// Check if user has any other active MFA devices
|
|
2009
2633
|
const allActiveDevices = (await this.mfaDeviceRepository.find({
|
|
2010
2634
|
where: {
|
|
2011
2635
|
userId: user.id,
|
|
2012
2636
|
isActive: true,
|
|
2013
2637
|
},
|
|
2014
2638
|
}));
|
|
2639
|
+
// If no active devices remain and user had MFA enabled, disable MFA
|
|
2015
2640
|
if (allActiveDevices.length === 0 && user.mfaEnabled) {
|
|
2016
2641
|
updateFields.mfaEnabled = false;
|
|
2017
2642
|
updateFields.mfaMethods = [];
|
|
@@ -2024,27 +2649,40 @@ class AuthService {
|
|
|
2024
2649
|
}
|
|
2025
2650
|
}
|
|
2026
2651
|
catch (error) {
|
|
2652
|
+
// Log error but don't fail the phone update
|
|
2653
|
+
// This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
|
|
2027
2654
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2028
2655
|
this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
|
|
2029
2656
|
}
|
|
2030
2657
|
}
|
|
2031
2658
|
}
|
|
2032
2659
|
}
|
|
2660
|
+
// Handle preferred MFA method
|
|
2033
2661
|
if (dto.preferredMfaMethod !== undefined) {
|
|
2034
2662
|
updateFields.preferredMfaMethod = dto.preferredMfaMethod;
|
|
2035
2663
|
}
|
|
2664
|
+
// Handle metadata merge
|
|
2036
2665
|
if (dto.metadata !== undefined) {
|
|
2037
2666
|
const existingMetadata = user.metadata || {};
|
|
2038
2667
|
updateFields.metadata = { ...existingMetadata, ...dto.metadata };
|
|
2039
2668
|
}
|
|
2669
|
+
// Update user in database - use internal id for update query
|
|
2040
2670
|
await this.userRepository.update(user.id, updateFields);
|
|
2671
|
+
// Fetch updated user - use internal id
|
|
2041
2672
|
const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
2042
2673
|
if (!updatedUser) {
|
|
2043
2674
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
|
|
2044
2675
|
}
|
|
2676
|
+
// ============================================================================
|
|
2677
|
+
// Audit: Record profile and attribute updates
|
|
2678
|
+
// ============================================================================
|
|
2045
2679
|
try {
|
|
2680
|
+
// Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
|
|
2681
|
+
// Note: ClientInfoService is used transparently by SessionService and AuditService
|
|
2046
2682
|
const updatedFieldNames = Object.keys(updateFields);
|
|
2683
|
+
// Build field changes map with before/after values
|
|
2047
2684
|
const fieldChanges = {};
|
|
2685
|
+
// Capture before/after values for each updated field
|
|
2048
2686
|
if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
|
|
2049
2687
|
fieldChanges.firstName = {
|
|
2050
2688
|
before: user.firstName ?? null,
|
|
@@ -2063,6 +2701,8 @@ class AuthService {
|
|
|
2063
2701
|
after: dto.username ?? null,
|
|
2064
2702
|
};
|
|
2065
2703
|
}
|
|
2704
|
+
// Note: email and phone are tracked separately with specific audit events,
|
|
2705
|
+
// but we include them in fieldChanges for completeness
|
|
2066
2706
|
if (dto.email !== undefined && dto.email !== user.email) {
|
|
2067
2707
|
fieldChanges.email = {
|
|
2068
2708
|
before: user.email ?? null,
|
|
@@ -2081,14 +2721,17 @@ class AuthService {
|
|
|
2081
2721
|
after: dto.preferredMfaMethod ?? null,
|
|
2082
2722
|
};
|
|
2083
2723
|
}
|
|
2724
|
+
// Handle metadata changes (merged, so track what was added/changed)
|
|
2084
2725
|
if (dto.metadata !== undefined) {
|
|
2085
2726
|
const oldMetadata = user.metadata || {};
|
|
2086
2727
|
const newMetadata = { ...oldMetadata, ...dto.metadata };
|
|
2087
2728
|
const metadataChanges = {};
|
|
2729
|
+
// Track all keys in new metadata
|
|
2088
2730
|
const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
|
|
2089
2731
|
for (const key of allKeys) {
|
|
2090
2732
|
const oldValue = oldMetadata[key];
|
|
2091
2733
|
const newValue = newMetadata[key];
|
|
2734
|
+
// Only track if value actually changed
|
|
2092
2735
|
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
2093
2736
|
metadataChanges[key] = {
|
|
2094
2737
|
before: oldValue ?? null,
|
|
@@ -2100,6 +2743,7 @@ class AuthService {
|
|
|
2100
2743
|
fieldChanges.metadata = metadataChanges;
|
|
2101
2744
|
}
|
|
2102
2745
|
}
|
|
2746
|
+
// Track verification status changes if email/phone changed
|
|
2103
2747
|
if (dto.email !== undefined && dto.email !== user.email) {
|
|
2104
2748
|
const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
|
|
2105
2749
|
if (emailVerificationChanged) {
|
|
@@ -2118,21 +2762,25 @@ class AuthService {
|
|
|
2118
2762
|
};
|
|
2119
2763
|
}
|
|
2120
2764
|
}
|
|
2765
|
+
// Record general profile update with field changes
|
|
2121
2766
|
await this.auditService?.recordEvent({
|
|
2122
2767
|
userId: user.id,
|
|
2123
2768
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
|
|
2124
2769
|
eventStatus: 'INFO',
|
|
2125
2770
|
metadata: {
|
|
2771
|
+
// Client info automatically included from context
|
|
2126
2772
|
updatedFields: updatedFieldNames,
|
|
2127
2773
|
fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
|
|
2128
2774
|
},
|
|
2129
2775
|
});
|
|
2776
|
+
// Record specific field changes
|
|
2130
2777
|
if (dto.email !== undefined && dto.email !== user.email) {
|
|
2131
2778
|
await this.auditService?.recordEvent({
|
|
2132
2779
|
userId: user.id,
|
|
2133
2780
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
|
|
2134
2781
|
eventStatus: 'INFO',
|
|
2135
2782
|
metadata: {
|
|
2783
|
+
// Client info automatically included from context
|
|
2136
2784
|
oldEmail: user.email,
|
|
2137
2785
|
newEmail: dto.email,
|
|
2138
2786
|
retainVerification: dto.retainVerification || false,
|
|
@@ -2145,6 +2793,7 @@ class AuthService {
|
|
|
2145
2793
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
|
|
2146
2794
|
eventStatus: 'INFO',
|
|
2147
2795
|
metadata: {
|
|
2796
|
+
// Client info automatically included from context
|
|
2148
2797
|
oldPhone: user.phone,
|
|
2149
2798
|
newPhone: dto.phone,
|
|
2150
2799
|
retainVerification: dto.retainVerification || false,
|
|
@@ -2157,6 +2806,7 @@ class AuthService {
|
|
|
2157
2806
|
eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
|
|
2158
2807
|
eventStatus: 'INFO',
|
|
2159
2808
|
metadata: {
|
|
2809
|
+
// Client info automatically included from context
|
|
2160
2810
|
oldUsername: user.username,
|
|
2161
2811
|
newUsername: dto.username,
|
|
2162
2812
|
},
|
|
@@ -2164,16 +2814,33 @@ class AuthService {
|
|
|
2164
2814
|
}
|
|
2165
2815
|
}
|
|
2166
2816
|
catch (auditError) {
|
|
2817
|
+
// Non-blocking: Log but continue
|
|
2167
2818
|
const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
|
|
2168
2819
|
this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
|
|
2169
2820
|
error: auditError,
|
|
2170
2821
|
userId: user.id,
|
|
2171
2822
|
});
|
|
2172
2823
|
}
|
|
2824
|
+
// Return user response DTO
|
|
2173
2825
|
return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
|
|
2174
2826
|
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Ensures email, phone, and username are unique for other users before update.
|
|
2829
|
+
*
|
|
2830
|
+
* Throws if another user already has the specified email, phone, or username.
|
|
2831
|
+
*
|
|
2832
|
+
* @param userId - Internal numeric user ID (excluded from check)
|
|
2833
|
+
* @param updateData - User fields to check for uniqueness
|
|
2834
|
+
* @throws {NAuthException} If a unique constraint is violated for email, phone, or username
|
|
2835
|
+
*
|
|
2836
|
+
* @example
|
|
2837
|
+
* ```typescript
|
|
2838
|
+
* await authService.validateUniquenessConstraints(1, { email: "test@example.com" });
|
|
2839
|
+
* ```
|
|
2840
|
+
*/
|
|
2175
2841
|
async validateUniquenessConstraints(userId, updateData) {
|
|
2176
2842
|
const conflicts = [];
|
|
2843
|
+
// Check email uniqueness
|
|
2177
2844
|
if (updateData.email) {
|
|
2178
2845
|
const existingUser = await this.userRepository.findOne({
|
|
2179
2846
|
where: { email: updateData.email },
|
|
@@ -2182,6 +2849,7 @@ class AuthService {
|
|
|
2182
2849
|
conflicts.push('Email already exists');
|
|
2183
2850
|
}
|
|
2184
2851
|
}
|
|
2852
|
+
// Check phone uniqueness
|
|
2185
2853
|
if (updateData.phone) {
|
|
2186
2854
|
const existingUser = await this.userRepository.findOne({
|
|
2187
2855
|
where: { phone: updateData.phone },
|
|
@@ -2190,6 +2858,7 @@ class AuthService {
|
|
|
2190
2858
|
conflicts.push('Phone number already exists');
|
|
2191
2859
|
}
|
|
2192
2860
|
}
|
|
2861
|
+
// Check username uniqueness
|
|
2193
2862
|
if (updateData.username) {
|
|
2194
2863
|
const existingUser = await this.userRepository.findOne({
|
|
2195
2864
|
where: { username: updateData.username },
|
|
@@ -2204,9 +2873,34 @@ class AuthService {
|
|
|
2204
2873
|
});
|
|
2205
2874
|
}
|
|
2206
2875
|
}
|
|
2876
|
+
// ============================================================================
|
|
2877
|
+
// Helper Methods
|
|
2878
|
+
// ============================================================================
|
|
2879
|
+
/**
|
|
2880
|
+
* Checks if the login identifier matches the specified allowed type.
|
|
2881
|
+
*
|
|
2882
|
+
* Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
|
|
2883
|
+
* according to the configured identifier type restriction.
|
|
2884
|
+
*
|
|
2885
|
+
* @param identifier - The login identifier to check (email, username, or phone)
|
|
2886
|
+
* @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
2887
|
+
* @returns True if the identifier conforms to the allowed type, otherwise false
|
|
2888
|
+
*
|
|
2889
|
+
* @example
|
|
2890
|
+
* ```typescript
|
|
2891
|
+
* // Email check
|
|
2892
|
+
* const valid = this.validateIdentifierType('user@example.com', 'email'); // true
|
|
2893
|
+
*
|
|
2894
|
+
* // Username check
|
|
2895
|
+
* const valid = this.validateIdentifierType('johndoe', 'username'); // true
|
|
2896
|
+
* ```
|
|
2897
|
+
*/
|
|
2207
2898
|
validateIdentifierType(identifier, allowedType) {
|
|
2899
|
+
// Check if identifier is an email (contains @)
|
|
2208
2900
|
const isEmail = identifier.includes('@');
|
|
2901
|
+
// Check if identifier looks like a phone (starts with + and contains digits)
|
|
2209
2902
|
const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
|
|
2903
|
+
// If not email or phone, assume it's a username
|
|
2210
2904
|
const isUsername = !isEmail && !isPhone;
|
|
2211
2905
|
switch (allowedType) {
|
|
2212
2906
|
case 'email':
|
|
@@ -2218,18 +2912,37 @@ class AuthService {
|
|
|
2218
2912
|
case 'email_or_username':
|
|
2219
2913
|
return isEmail || isUsername;
|
|
2220
2914
|
default:
|
|
2221
|
-
return true;
|
|
2915
|
+
return true; // No restriction
|
|
2222
2916
|
}
|
|
2223
2917
|
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Retrieves a user entity by login identifier.
|
|
2920
|
+
*
|
|
2921
|
+
* Performs a lookup for a user by email, username, or phone number.
|
|
2922
|
+
* The search respects the identifierType restriction when provided, limiting which fields are queried.
|
|
2923
|
+
*
|
|
2924
|
+
* @param identifier - Login credential (email, username, or phone)
|
|
2925
|
+
* @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
|
|
2926
|
+
* @returns The user entity if found, otherwise null
|
|
2927
|
+
*
|
|
2928
|
+
* @example
|
|
2929
|
+
* ```typescript
|
|
2930
|
+
* const user = await this.findUserByIdentifier('user@example.com');
|
|
2931
|
+
* const user2 = await this.findUserByIdentifier('johndoe', 'username');
|
|
2932
|
+
* ```
|
|
2933
|
+
*/
|
|
2224
2934
|
async findUserByIdentifier(identifier, identifierType) {
|
|
2225
2935
|
const queryBuilder = this.userRepository.createQueryBuilder('user');
|
|
2936
|
+
// Build query based on identifier type restriction
|
|
2226
2937
|
if (!identifierType) {
|
|
2938
|
+
// No restriction - search all fields
|
|
2227
2939
|
queryBuilder
|
|
2228
2940
|
.where('user.email = :identifier', { identifier })
|
|
2229
2941
|
.orWhere('user.username = :identifier', { identifier })
|
|
2230
2942
|
.orWhere('user.phone = :identifier', { identifier });
|
|
2231
2943
|
}
|
|
2232
2944
|
else {
|
|
2945
|
+
// Apply restriction based on identifier type
|
|
2233
2946
|
switch (identifierType) {
|
|
2234
2947
|
case 'email':
|
|
2235
2948
|
queryBuilder.where('user.email = :identifier', { identifier });
|
|
@@ -2247,6 +2960,7 @@ class AuthService {
|
|
|
2247
2960
|
break;
|
|
2248
2961
|
}
|
|
2249
2962
|
}
|
|
2963
|
+
// Select only columns required for login checks and response shaping to reduce row size
|
|
2250
2964
|
queryBuilder.select([
|
|
2251
2965
|
'user.id',
|
|
2252
2966
|
'user.sub',
|
|
@@ -2263,28 +2977,65 @@ class AuthService {
|
|
|
2263
2977
|
'user.preferredMfaMethod',
|
|
2264
2978
|
'user.isEmailVerified',
|
|
2265
2979
|
'user.isPhoneVerified',
|
|
2266
|
-
'user.mfaExempt',
|
|
2980
|
+
'user.mfaExempt', // Required for MFA exemption check in challenge flow
|
|
2981
|
+
// The following are used for messaging/challenge determination when needed
|
|
2267
2982
|
'user.socialProviders',
|
|
2268
2983
|
'user.backupCodes',
|
|
2269
2984
|
]);
|
|
2270
2985
|
return (await queryBuilder.getOne());
|
|
2271
2986
|
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Handles a failed login by recording the attempt, applying IP-based lockout policy,
|
|
2989
|
+
* and invoking relevant hooks.
|
|
2990
|
+
*
|
|
2991
|
+
* @param identifier - User identifier (email/username/phone)
|
|
2992
|
+
* @param reason - Optional reason for failure
|
|
2993
|
+
* @returns Promise<void>
|
|
2994
|
+
*
|
|
2995
|
+
* @example
|
|
2996
|
+
* ```typescript
|
|
2997
|
+
* await authService.handleFailedLogin('user@example.com', 'invalid_credentials');
|
|
2998
|
+
* ```
|
|
2999
|
+
*/
|
|
2272
3000
|
async handleFailedLogin(identifier, reason) {
|
|
3001
|
+
// Get client IP address for lockout tracking
|
|
2273
3002
|
const clientInfo = this.clientInfoService.get();
|
|
2274
3003
|
const ipAddress = clientInfo.ipAddress;
|
|
3004
|
+
// Record failed attempt
|
|
2275
3005
|
await this.recordLoginAttempt(identifier, false, reason);
|
|
3006
|
+
// Increment IP-based lockout counter if enabled
|
|
2276
3007
|
if (this.config.lockout?.enabled && ipAddress) {
|
|
2277
3008
|
const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
|
|
3009
|
+
// Lock IP if max attempts reached
|
|
2278
3010
|
if (attempts >= (this.config.lockout.maxAttempts || 5)) {
|
|
2279
|
-
await this.accountLockoutStorage.blockIpAdresss(ipAddress, this.config.lockout.duration || 900,
|
|
2280
|
-
|
|
2281
|
-
|
|
3011
|
+
await this.accountLockoutStorage.blockIpAdresss(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
|
|
3012
|
+
'Too many failed login attempts from this IP');
|
|
3013
|
+
// // Execute hook with IP address
|
|
3014
|
+
// if (this.config.hooks?.afterAccountLock) {
|
|
3015
|
+
// await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
|
|
3016
|
+
// }
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
// // Execute hook
|
|
3020
|
+
// if (this.config.hooks?.afterLoginFailed) {
|
|
3021
|
+
// await this.config.hooks.afterLoginFailed(identifier, reason || 'unknown');
|
|
3022
|
+
// }
|
|
2282
3023
|
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Records a login attempt with client context.
|
|
3026
|
+
*
|
|
3027
|
+
* @param email - User's email address
|
|
3028
|
+
* @param success - True if login succeeded, false if failed
|
|
3029
|
+
* @param failureReason - Optional reason for failure
|
|
3030
|
+
* @param userId - Optional internal user ID (only for successful logins)
|
|
3031
|
+
* @returns Promise<void>
|
|
3032
|
+
*/
|
|
2283
3033
|
async recordLoginAttempt(email, success, failureReason, userId) {
|
|
3034
|
+
// Get client info from context
|
|
2284
3035
|
const clientInfo = this.clientInfoService.get();
|
|
2285
3036
|
const attempt = this.loginAttemptRepository.create({
|
|
2286
3037
|
email,
|
|
2287
|
-
userId,
|
|
3038
|
+
userId, // Internal user ID (integer)
|
|
2288
3039
|
ipAddress: clientInfo.ipAddress,
|
|
2289
3040
|
userAgent: clientInfo.userAgent,
|
|
2290
3041
|
success,
|
|
@@ -2292,10 +3043,28 @@ class AuthService {
|
|
|
2292
3043
|
});
|
|
2293
3044
|
await this.loginAttemptRepository.save(attempt);
|
|
2294
3045
|
}
|
|
3046
|
+
/**
|
|
3047
|
+
* Get user by ID (sub)
|
|
3048
|
+
* @param sub - User sub (external identifier)
|
|
3049
|
+
* @returns User entity or null
|
|
3050
|
+
*/
|
|
2295
3051
|
async getUserById(dto) {
|
|
2296
3052
|
const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
|
|
2297
3053
|
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2298
3054
|
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Get user by email address.
|
|
3057
|
+
*
|
|
3058
|
+
* @param email - User email
|
|
3059
|
+
* @param requireEmailVerified - Only return user if email is verified (default: false)
|
|
3060
|
+
* @returns User entity or null
|
|
3061
|
+
* @internal - For use by social auth providers
|
|
3062
|
+
*
|
|
3063
|
+
* @example
|
|
3064
|
+
* ```typescript
|
|
3065
|
+
* const user = await authService.getUserByEmail('user@example.com', true);
|
|
3066
|
+
* ```
|
|
3067
|
+
*/
|
|
2299
3068
|
async getUserByEmail(dto) {
|
|
2300
3069
|
const where = dto.requireEmailVerified
|
|
2301
3070
|
? { email: dto.email, isEmailVerified: true }
|
|
@@ -2303,11 +3072,25 @@ class AuthService {
|
|
|
2303
3072
|
const user = (await this.userRepository.findOne({ where }));
|
|
2304
3073
|
return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
|
|
2305
3074
|
}
|
|
3075
|
+
/**
|
|
3076
|
+
* Require user to change password at next login.
|
|
3077
|
+
*
|
|
3078
|
+
* Throws if user not found or has no password set (e.g. social login only).
|
|
3079
|
+
*
|
|
3080
|
+
* @param userId - User's sub identifier
|
|
3081
|
+
* @returns Resolves when flag is set
|
|
3082
|
+
* @throws {NAuthException} If user is not found or cannot change password
|
|
3083
|
+
*
|
|
3084
|
+
* @example
|
|
3085
|
+
* await authService.setMustChangePassword('user-uuid-123');
|
|
3086
|
+
*/
|
|
2306
3087
|
async setMustChangePassword(dto) {
|
|
2307
3088
|
const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
|
|
2308
3089
|
if (!user) {
|
|
2309
3090
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2310
3091
|
}
|
|
3092
|
+
// CRITICAL PROTECTION: Only allow for users with password authentication
|
|
3093
|
+
// Pure social users cannot be forced to change password
|
|
2311
3094
|
if (!user.passwordHash) {
|
|
2312
3095
|
this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
|
|
2313
3096
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not available. This account uses social authentication only and has no password.');
|
|
@@ -2316,14 +3099,53 @@ class AuthService {
|
|
|
2316
3099
|
this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
|
|
2317
3100
|
return { success: true };
|
|
2318
3101
|
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Admin-only: Reset a user's password by identifier.
|
|
3104
|
+
*
|
|
3105
|
+
* Allows administrators to reset a user's password using any identifier
|
|
3106
|
+
* (email, username, phone, or sub). Automatically revokes sessions and optionally
|
|
3107
|
+
* requires password change on next login using the existing challenge system.
|
|
3108
|
+
*
|
|
3109
|
+
* SECURITY: This is an admin-only operation. Ensure proper authorization
|
|
3110
|
+
* checks are in place before calling this method.
|
|
3111
|
+
*
|
|
3112
|
+
* @param dto - Admin reset password request
|
|
3113
|
+
* @returns Response with success status and session revocation count
|
|
3114
|
+
* @throws {NAuthException} If user not found, user has no password (social-only), or password validation fails
|
|
3115
|
+
*
|
|
3116
|
+
* @example
|
|
3117
|
+
* ```typescript
|
|
3118
|
+
* // Reset with force password change
|
|
3119
|
+
* const result = await authService.adminSetPassword({
|
|
3120
|
+
* identifier: 'user@example.com',
|
|
3121
|
+
* newPassword: 'NewSecurePassword123!',
|
|
3122
|
+
* mustChangePassword: true,
|
|
3123
|
+
* revokeSessions: true
|
|
3124
|
+
* });
|
|
3125
|
+
*
|
|
3126
|
+
* // Reset without forcing password change
|
|
3127
|
+
* const result = await authService.adminSetPassword({
|
|
3128
|
+
* identifier: 'a21b654c-2746-4168-acee-c175083a65cd',
|
|
3129
|
+
* newPassword: 'NewSecurePassword123!',
|
|
3130
|
+
* mustChangePassword: false
|
|
3131
|
+
* });
|
|
3132
|
+
* ```
|
|
3133
|
+
*/
|
|
2319
3134
|
async adminSetPassword(dto) {
|
|
2320
3135
|
this.logger?.log?.(`Admin password reset requested for identifier: ${dto.identifier}`);
|
|
2321
3136
|
this.logger?.debug?.(`Reset details: { identifier: ${dto.identifier}, mustChangePassword: ${dto.mustChangePassword ?? true}, revokeSessions: ${dto.revokeSessions ?? true} }`);
|
|
3137
|
+
// ============================================================================
|
|
3138
|
+
// Find User by Identifier
|
|
3139
|
+
// ============================================================================
|
|
3140
|
+
// Support multiple identifier types: email, username, phone, or sub (UUID)
|
|
2322
3141
|
let user = null;
|
|
3142
|
+
// Try to find by sub (UUID) first if it looks like a UUID.
|
|
3143
|
+
// WHY: Many deployments treat `sub` as the primary immutable identifier.
|
|
2323
3144
|
if ((0, class_validator_1.isUUID)(dto.identifier)) {
|
|
2324
3145
|
this.logger?.debug?.(`Identifier appears to be UUID, searching by sub: ${dto.identifier}`);
|
|
2325
3146
|
user = (await this.userRepository.findOne({ where: { sub: dto.identifier } }));
|
|
2326
3147
|
}
|
|
3148
|
+
// If not found by sub, try by identifier (email, username, phone)
|
|
2327
3149
|
if (!user) {
|
|
2328
3150
|
this.logger?.debug?.(`Searching by identifier (email/username/phone): ${dto.identifier}`);
|
|
2329
3151
|
user = await this.findUserByIdentifier(dto.identifier);
|
|
@@ -2332,11 +3154,16 @@ class AuthService {
|
|
|
2332
3154
|
this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
|
|
2333
3155
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2334
3156
|
}
|
|
3157
|
+
// ============================================================================
|
|
3158
|
+
// Validate User Can Have Password Reset
|
|
3159
|
+
// ============================================================================
|
|
3160
|
+
// CRITICAL PROTECTION: Only allow for users with password authentication
|
|
3161
|
+
// Pure social users cannot have password reset (they don't have passwords)
|
|
2335
3162
|
if (!user.passwordHash) {
|
|
2336
3163
|
this.logger?.warn?.(`Password reset failed - user doesn't have a password (pure social signup): ${user.sub}`);
|
|
2337
3164
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password reset not available. This account uses social authentication only and has no password.');
|
|
2338
3165
|
}
|
|
2339
|
-
const mustChangePassword = dto.mustChangePassword ?? true;
|
|
3166
|
+
const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
|
|
2340
3167
|
const revokeSessions = dto.revokeSessions !== false;
|
|
2341
3168
|
const { sessionsRevoked } = await this.updateUserPassword({
|
|
2342
3169
|
user,
|
|
@@ -2355,27 +3182,59 @@ class AuthService {
|
|
|
2355
3182
|
},
|
|
2356
3183
|
},
|
|
2357
3184
|
});
|
|
3185
|
+
// ============================================================================
|
|
3186
|
+
// Return Response
|
|
3187
|
+
// ============================================================================
|
|
2358
3188
|
return {
|
|
2359
3189
|
success: true,
|
|
2360
3190
|
mustChangePassword,
|
|
2361
3191
|
sessionsRevoked,
|
|
2362
3192
|
};
|
|
2363
3193
|
}
|
|
3194
|
+
// ============================================================================
|
|
3195
|
+
// Forgot Password (Account Recovery)
|
|
3196
|
+
// ============================================================================
|
|
3197
|
+
/**
|
|
3198
|
+
* Request a password reset code for an account.
|
|
3199
|
+
*
|
|
3200
|
+
* Security:
|
|
3201
|
+
* - Avoids account enumeration: returns success even when user is not found.
|
|
3202
|
+
* - Delivery is best-effort; errors are logged but should not reveal account existence.
|
|
3203
|
+
*
|
|
3204
|
+
* Channel selection (per config.signup.verificationMethod):
|
|
3205
|
+
* - 'none': send to email if available; else phone (if available)
|
|
3206
|
+
* - 'email': only send to verified email
|
|
3207
|
+
* - 'phone': only send to verified phone
|
|
3208
|
+
* - 'both': prefer verified email; fallback to verified phone
|
|
3209
|
+
*
|
|
3210
|
+
* @param dto - Forgot password request payload
|
|
3211
|
+
* @returns Delivery metadata (masked destination) when available
|
|
3212
|
+
*/
|
|
2364
3213
|
async forgotPassword(dto) {
|
|
2365
3214
|
const response = { success: true };
|
|
2366
3215
|
if (!this.passwordResetService) {
|
|
3216
|
+
// Do not leak configuration details to clients.
|
|
2367
3217
|
this.logger?.warn?.('PasswordResetService not configured; forgotPassword will not send any delivery');
|
|
2368
3218
|
return response;
|
|
2369
3219
|
}
|
|
3220
|
+
// Respect identifier type restrictions (if configured)
|
|
2370
3221
|
if (this.config.login?.identifierType &&
|
|
2371
3222
|
!this.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
|
|
3223
|
+
// Non-enumerating: return success without sending
|
|
2372
3224
|
return response;
|
|
2373
3225
|
}
|
|
2374
3226
|
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
2375
3227
|
if (!user) {
|
|
2376
|
-
return response;
|
|
3228
|
+
return response; // Non-enumerating
|
|
2377
3229
|
}
|
|
3230
|
+
// Only password-capable accounts can use forgot-password.
|
|
3231
|
+
// Hybrid (password + social) is allowed (passwordHash exists).
|
|
2378
3232
|
if (!user.passwordHash) {
|
|
3233
|
+
// ============================================================================
|
|
3234
|
+
// Security: record attempt for social-only accounts (no password set)
|
|
3235
|
+
// ============================================================================
|
|
3236
|
+
// WHY: A malicious actor may spam forgot-password to learn about accounts or to harass users.
|
|
3237
|
+
// We keep the API response non-enumerating, but still record an audit event for observability.
|
|
2379
3238
|
this.logger?.warn?.(`Password reset requested for social-only account; ignoring for user: ${user.sub}`);
|
|
2380
3239
|
try {
|
|
2381
3240
|
await this.auditService?.recordEvent({
|
|
@@ -2397,8 +3256,12 @@ class AuthService {
|
|
|
2397
3256
|
return response;
|
|
2398
3257
|
}
|
|
2399
3258
|
const verificationMethod = this.config.signup?.verificationMethod ?? 'email';
|
|
3259
|
+
// ============================================================================
|
|
3260
|
+
// Determine delivery channel
|
|
3261
|
+
// ============================================================================
|
|
2400
3262
|
let delivery;
|
|
2401
3263
|
if (verificationMethod === 'none') {
|
|
3264
|
+
// Rare config: no verification required. Still prefer email if present, else phone.
|
|
2402
3265
|
if (user.email)
|
|
2403
3266
|
delivery = 'email';
|
|
2404
3267
|
else if (user.phone)
|
|
@@ -2419,6 +3282,7 @@ class AuthService {
|
|
|
2419
3282
|
delivery = 'sms';
|
|
2420
3283
|
}
|
|
2421
3284
|
if (!delivery) {
|
|
3285
|
+
// Non-enumerating: return success without sending
|
|
2422
3286
|
return response;
|
|
2423
3287
|
}
|
|
2424
3288
|
try {
|
|
@@ -2428,20 +3292,36 @@ class AuthService {
|
|
|
2428
3292
|
response.expiresIn = result.expiresIn;
|
|
2429
3293
|
}
|
|
2430
3294
|
catch (error) {
|
|
3295
|
+
// Rate limit is safe to return (still does not reveal existence when user exists).
|
|
2431
3296
|
if (error instanceof nauth_exception_1.NAuthException && error.code === error_codes_enum_1.AuthErrorCode.RATE_LIMIT_PASSWORD_RESET) {
|
|
2432
3297
|
throw error;
|
|
2433
3298
|
}
|
|
3299
|
+
// Non-blocking: log and return success
|
|
2434
3300
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
2435
3301
|
this.logger?.error?.(`Failed to send password reset code: ${errorMessage}`, { error });
|
|
2436
3302
|
}
|
|
2437
3303
|
return response;
|
|
2438
3304
|
}
|
|
3305
|
+
/**
|
|
3306
|
+
* Confirm a password reset by validating the reset code and setting a new password.
|
|
3307
|
+
*
|
|
3308
|
+
* Security:
|
|
3309
|
+
* - Uses platform-agnostic errors via NAuthException
|
|
3310
|
+
* - Verifies reset code via PasswordResetService
|
|
3311
|
+
* - Enforces password policy and history
|
|
3312
|
+
* - Revokes all sessions upon successful reset
|
|
3313
|
+
*
|
|
3314
|
+
* @param dto - Confirm forgot password payload
|
|
3315
|
+
* @returns Success response
|
|
3316
|
+
* @throws {NAuthException} PASSWORD_RESET_CODE_INVALID | PASSWORD_RESET_CODE_EXPIRED | PASSWORD_RESET_MAX_ATTEMPTS
|
|
3317
|
+
*/
|
|
2439
3318
|
async confirmForgotPassword(dto) {
|
|
2440
3319
|
if (!this.passwordResetService) {
|
|
2441
3320
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
|
|
2442
3321
|
}
|
|
2443
3322
|
const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
|
|
2444
3323
|
if (!user || !user.passwordHash) {
|
|
3324
|
+
// Non-enumerating: treat as invalid code
|
|
2445
3325
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
|
|
2446
3326
|
}
|
|
2447
3327
|
const { sessionsRevoked: _sessionsRevoked } = await this.updateUserPassword({
|
|
@@ -2451,6 +3331,7 @@ class AuthService {
|
|
|
2451
3331
|
revokeSessions: true,
|
|
2452
3332
|
revokeReason: 'Password reset',
|
|
2453
3333
|
beforePersist: async () => {
|
|
3334
|
+
// Consume code (throws if invalid/expired/too many attempts)
|
|
2454
3335
|
await this.passwordResetService.consumeValidCode(user, dto.code);
|
|
2455
3336
|
},
|
|
2456
3337
|
audit: {
|
|
@@ -2463,12 +3344,37 @@ class AuthService {
|
|
|
2463
3344
|
});
|
|
2464
3345
|
return { success: true, mustChangePassword: false };
|
|
2465
3346
|
}
|
|
3347
|
+
// ============================================================================
|
|
3348
|
+
// Internal Password Update Orchestration (Single Source of Truth)
|
|
3349
|
+
// ============================================================================
|
|
3350
|
+
/**
|
|
3351
|
+
* Centralized password update flow used by:
|
|
3352
|
+
* - changePassword()
|
|
3353
|
+
* - confirmForgotPassword()
|
|
3354
|
+
* - adminSetPassword()
|
|
3355
|
+
* - FORCE_CHANGE_PASSWORD challenge handler
|
|
3356
|
+
*
|
|
3357
|
+
* WHY:
|
|
3358
|
+
* - Prevent logic drift between different password-changing entrypoints
|
|
3359
|
+
* - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
|
|
3360
|
+
*
|
|
3361
|
+
* @param params - Password update parameters
|
|
3362
|
+
* @returns Sessions revoked count (0 when not revoked)
|
|
3363
|
+
* @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
|
|
3364
|
+
*/
|
|
2466
3365
|
async updateUserPassword(params) {
|
|
2467
3366
|
const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
|
|
3367
|
+
// ============================================================================
|
|
3368
|
+
// Load full user entity (important for passwordHistory serialization + reuse checks)
|
|
3369
|
+
// ============================================================================
|
|
3370
|
+
// WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
|
|
2468
3371
|
const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
|
|
2469
3372
|
if (!userEntity) {
|
|
2470
3373
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
|
|
2471
3374
|
}
|
|
3375
|
+
// ============================================================================
|
|
3376
|
+
// Validate new password + history
|
|
3377
|
+
// ============================================================================
|
|
2472
3378
|
const validation = await this.passwordService.validatePassword(newPassword, {
|
|
2473
3379
|
email: userEntity.email,
|
|
2474
3380
|
username: userEntity.username || undefined,
|
|
@@ -2488,9 +3394,13 @@ class AuthService {
|
|
|
2488
3394
|
throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
|
|
2489
3395
|
}
|
|
2490
3396
|
}
|
|
3397
|
+
// Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
|
|
2491
3398
|
if (beforePersist) {
|
|
2492
3399
|
await beforePersist();
|
|
2493
3400
|
}
|
|
3401
|
+
// ============================================================================
|
|
3402
|
+
// Persist password update
|
|
3403
|
+
// ============================================================================
|
|
2494
3404
|
const newHash = await this.passwordService.hashPassword(newPassword);
|
|
2495
3405
|
const newHistory = userEntity.passwordHash
|
|
2496
3406
|
? this.passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
|
|
@@ -2500,10 +3410,16 @@ class AuthService {
|
|
|
2500
3410
|
userEntity.passwordHistory = newHistory;
|
|
2501
3411
|
userEntity.mustChangePassword = mustChangePassword;
|
|
2502
3412
|
await this.userRepository.save(userEntity);
|
|
3413
|
+
// ============================================================================
|
|
3414
|
+
// Session revocation
|
|
3415
|
+
// ============================================================================
|
|
2503
3416
|
let sessionsRevoked = 0;
|
|
2504
3417
|
if (revokeSessions) {
|
|
2505
3418
|
sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
|
|
2506
3419
|
}
|
|
3420
|
+
// ============================================================================
|
|
3421
|
+
// Audit
|
|
3422
|
+
// ============================================================================
|
|
2507
3423
|
if (audit) {
|
|
2508
3424
|
try {
|
|
2509
3425
|
await this.auditService?.recordEvent({
|