Skip to content

feat(auth): ship reset-password endpoint (#369)#405

Merged
ayshadogo merged 4 commits into
Dfunder:mainfrom
dzekojohn4:fix/issue-369-password-reset
Jun 30, 2026
Merged

feat(auth): ship reset-password endpoint (#369)#405
ayshadogo merged 4 commits into
Dfunder:mainfrom
dzekojohn4:fix/issue-369-password-reset

Conversation

@dzekojohn4

@dzekojohn4 dzekojohn4 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Closes #369
Closes #368
Closes #367
Closes #366

What

Implements PATCH /api/auth/reset-password/:token. Validates the token (SHA-256 digest + expiry), bcrypt-hashes the new password, and clears the reset token plus all refresh-token state in a single update.

Endpoint

PATCH /api/auth/reset-password/:token
Body: { "password": "newPassword123" }
  • 200 - password reset ({ success: true, message: "Password reset successfully. Please log in with your new password." })
  • 400 - token missing, malformed, unknown, or expired (generic message - does not leak which condition failed)

Security decisions

  • SHA-256 storage. The DB stores sha256(rawToken) rather than the raw token. A DB snapshot leak cannot be replayed against any pending reset. hashResetToken() is exported from AuthService so the future forgot-password flow (Implement Forgot Password Endpoint #368) can mint and store tokens using the same digest lookup.
  • Manual bcrypt on update. usersService.update() uses findOneAndUpdate, which bypasses the pre-save hook. The new password is hashed manually before being passed in.
  • Full session invalidation. On success, passwordResetToken, passwordResetExpires, refreshTokenHash, and refreshTokenExpires are all cleared so previously issued refresh tokens can no longer be used.
  • Brute-force protection. Endpoint is wrapped in AuthThrottlerGuard (10 req / 15 min / IP) layered on top of the global ThrottlerGuard.
  • Soft-delete safe. The findOneAndUpdate pre-hooks exclude soft-deleted users, so a deactivated account cannot have its password reset.
  • Generic failure message. All four failure modes (missing token, unknown token, null expiry, expired token) emit the same Invalid or expired reset token message - no info leak between "token doesn't exist" and "token expired".

Files

  • new src/auth/dto/reset-password.dto.ts - body validation (@IsString, @MinLength(8), @MaxLength(128))
  • src/auth/auth.service.ts - resetPassword(token, newPassword) + exported hashResetToken(token) helper
  • src/auth/auth.controller.ts - PATCH reset-password/:token with @Public() + @UseGuards(AuthThrottlerGuard); BadRequestException mapped to 400 envelope
  • src/users/users.service.ts - docstring clarified on findByPasswordResetToken (caller must pass hashed input)
  • src/auth/auth.service.spec.ts - 4 new tests: happy path, unknown token, expired token, same-message invariant

Tests

Four unit tests cover the new AuthService.resetPassword:

  1. Valid token + new password -> findByPasswordResetToken called with the SHA-256 digest; update carries a bcrypt-hashed new password and clears all four token-bearing fields.
  2. Unknown token -> BadRequestException; usersService.update never called.
  3. Expired token -> BadRequestException; usersService.update never called.
  4. Unknown vs. expired token emit the same message - confirms no info leak.

Out of scope

  • The companion POST /api/auth/forgot-password request endpoint (issue Implement Forgot Password Endpoint #368) that mints and emails reset tokens is a separate PR; hashResetToken is exported so it can share the same digest lookup convention.

chore and others added 4 commits June 28, 2026 18:36
The recent upstream pull left two parallel copies of JWT strategy/guard code (root auth/* and auth/strategies|guards/*). Each pair contained broken syntax: auth.module.ts had a duplicate JwtStrategy import and src/auth/jwt-auth.guard.ts had two smushed class declarations. Consolidate to one canonical location: src/auth/jwt.strategy.ts, src/auth/jwt-auth.guard.ts, and add the missing src/auth/decorators/public.decorator.ts that the unified guard depends on. No behavior change.
## JWT auth middleware
- New global `JwtAuthGuard` registered via `APP_GUARD`; honours `@Public()` opt-out through `Reflector`.
- `JwtStrategy` returns a `JwtPayload` (`{sub, email, role}`) instead of the full user.
- New `@CurrentUser()` and `@Public()` decorators under `src/auth/decorators/`.
- `JwtPayload.role` narrowed from `string` to `UserRole`; `RolesGuard` and the admin controller consume the enum directly.

## Consumer migration
- `UsersController.getKycStatus` fetches the full user via `UsersService.findById(currentUser.sub)`.
- `UsersController.submitKyc` and `KycController.submitKyc` pass `currentUser.sub` (no more `req.user._id` reads).
- `HealthController` and `AuthController.register` marked `@Public()` so the global guard doesn't 401 them.
- New `JwtPayload` shape is the published contract; downstream modules depend on `currentUser.sub/email/role`.

## Duplicate KYC route consolidated
- Two parallel KYC stacks collided on `POST /users/me/kyc`. Kept the canonical `src/kyc/` stack (`KycController`, `KycService`, `KycSchema` — proper disk storage, UUID filenames, `UsersService` layering, admin review methods).
- Deleted `src/users/kyc.service.ts`, `src/users/schemas/kyc.schema.ts`, the duplicate `submitKyc` handler on `UsersController`, and the duplicate `Kyc` Mongo `forFeature` registration in `UsersModule` (which also resolved a duplicate `name: Kyc` Mongoose registration).
- Patched canonical `KycService.create()` to stamp `user.kycSubmissionDate` on submission and `KycService.updateStatus()` to stamp `user.kycReviewNotes` on review — both required for `GET /users/me/kyc` to return meaningful data.

## Housekeeping
- Moved `src/auth/jwt-auth.guard.ts` → `src/auth/guards/jwt-auth.guard.ts`.
- Deleted dead duplicate `src/decorators/public.decorator.ts`.
- Documented the `APP_GUARD` execution order in `src/app.module.ts` (ThrottlerGuard → JwtAuthGuard → `@Public()` short-circuit).
- Dropped redundant `JwtAuthModule` import in `UsersModule` (the global guard covers it).
- PATCH /api/auth/reset-password/:token validates the token (SHA-256
  digest + expiry), bcrypt-hashes the new password, and clears the
  reset token, reset expiry, refresh-token hash, and refresh-token
  expiry in a single update.
- All four failure modes (missing/unknown/null-expiry/expired token)
  emit the same generic message so the endpoint leaks no info.
- Endpoint is @public() and protected by AuthThrottlerGuard
  (10 req / 15 min / IP) layered on top of the global ThrottlerGuard.
@drips-wave

drips-wave Bot commented Jun 29, 2026

Copy link
Copy Markdown

@dzekojohn4 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@ayshadogo ayshadogo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ayshadogo ayshadogo merged commit 6666813 into Dfunder:main Jun 30, 2026
@grantfox-oss grantfox-oss Bot mentioned this pull request Jun 30, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Reset Password Endpoint Implement Forgot Password Endpoint Implement Logout Endpoint Implement Token Refresh Endpoint

3 participants