Files
tessera-ctl/.planning/phases/02-authentication-multi-tenancy/02-03-PLAN.md
T
schalli e8e89686f5 docs(02): create phase 2 authentication & multi-tenancy plans
5 plans across 4 waves covering all 9 requirements (AUTH-01..06, TNNT-01..03)
and 18 locked decisions (D-01..D-18):
- Plan 01 (W1): Backend auth foundation with Prisma schema, RLS, JWT, admin seed
- Plan 02 (W2): Frontend auth flow, login page, user/tenant CRUD admin pages
- Plan 03 (W2): SUS package verification, password reset, force-change interceptor
- Plan 04 (W3): LDAP sync service, per-tenant config, field mapping, admin UI
- Plan 05 (W4): Visual verification of complete auth and multi-tenancy system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:10:13 +02:00

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
02-authentication-multi-tenancy 03 execute 2
02-01
apps/api/src/mail/mail.module.ts
apps/api/src/mail/mail.service.ts
apps/api/src/auth/auth.service.ts
apps/api/src/auth/auth.controller.ts
apps/api/src/auth/dto/reset-password.dto.ts
apps/api/src/auth/dto/change-password.dto.ts
apps/api/src/auth/interceptors/force-password-change.interceptor.ts
apps/api/src/app.module.ts
apps/api/package.json
apps/web/src/app/(auth)/reset-password/page.tsx
apps/web/src/app/(auth)/reset-password/[token]/page.tsx
apps/web/src/app/(portal)/change-password/page.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
docker-compose.yml
docker-compose.dev.yml
false
AUTH-03
AUTH-04
service why env_vars dashboard_config
smtp Password reset emails require SMTP configuration
name source
TESSERA_SMTP_HOST Your SMTP server hostname
name source
TESSERA_SMTP_PORT SMTP port (587 for STARTTLS, 465 for SSL)
name source
TESSERA_SMTP_USER SMTP authentication username
name source
TESSERA_SMTP_PASSWORD SMTP authentication password
name source
TESSERA_SMTP_FROM Sender email address
task location
For development, MailHog is added to docker-compose.dev.yml automatically (no config needed) http://localhost:8025 for MailHog web UI
truths artifacts key_links
SUS-flagged packages (ldapts, @nestjs-modules/mailer, nodemailer) are verified by human before installation
User can request password reset via email with a time-limited token (D-03)
Admin can manually reset a user's password (D-03)
User with mustChangePassword=true is forced to change password before accessing any other page (D-06)
Password reset token expires after 1 hour and can only be used once
MailHog is available in development docker-compose for email testing
path provides exports
apps/api/src/mail/mail.module.ts NestJS mailer module with SMTP transport from ENV
MailModule
path provides exports
apps/api/src/mail/mail.service.ts Email sending for password reset
MailService
path provides exports
apps/api/src/auth/interceptors/force-password-change.interceptor.ts Global interceptor checking mustChangePassword
ForcePasswordChangeInterceptor
path provides min_lines
apps/web/src/app/(auth)/reset-password/page.tsx Password reset request form 30
path provides min_lines
apps/web/src/app/(auth)/reset-password/[token]/page.tsx Password reset form with token validation 40
from to via pattern
apps/api/src/auth/auth.service.ts apps/api/src/mail/mail.service.ts Send password reset email with token link mailService.*sendPasswordReset
from to via pattern
apps/api/src/auth/interceptors/force-password-change.interceptor.ts apps/api/src/auth/auth.controller.ts Returns 403 FORCE_PASSWORD_CHANGE on all routes except change-password FORCE_PASSWORD_CHANGE
Package legitimacy verification for SUS-flagged packages, then password reset flow (self-service via email and admin manual reset per D-03), force-password-change interceptor (D-06), and MailModule with SMTP configuration.

This plan addresses security-critical password management: D-03 (password reset via email + admin reset), D-06 (force change on first login), and the package legitimacy gate for SUS packages identified in RESEARCH.md.

Purpose: Complete the authentication lifecycle -- users who forget passwords can self-recover, admins can force password changes, and the first login experience respects D-06.

Output: Working password reset email flow, force-change interceptor, MailHog in dev compose, verified SUS packages installed.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-authentication-multi-tenancy/02-CONTEXT.md @.planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md @.planning/phases/02-authentication-multi-tenancy/02-01-SUMMARY.md @apps/api/src/auth/auth.service.ts @apps/api/src/auth/auth.controller.ts @apps/api/src/app.module.ts @apps/api/package.json @docker-compose.yml @docker-compose.dev.yml

Artifacts this phase produces

See Plan 02-01 for the full artifacts table.

Task 1: Verify SUS-flagged npm packages before installation RESEARCH.md Package Legitimacy Audit flagged three packages as SUS (too-new publish dates on well-established packages): - ldapts (6+ yrs, 400K/wk downloads) -- flagged for recent publish - @nestjs-modules/mailer (5+ yrs, 350K/wk downloads) -- flagged for recent publish - nodemailer (14+ yrs, 17M/wk downloads) -- flagged for recent publish
All three are well-established packages with millions of cumulative downloads. The SUS flag is due to routine version updates, not suspicious origin. However, per the supply chain security protocol, human verification is required before installation.
Please verify each package is legitimate by checking their npm pages:
1. **ldapts**: Visit https://www.npmjs.com/package/ldapts
   - Verify publisher is the expected maintainer (ldapts org)
   - Verify recent publish is a routine version update (not a hijack)
   - Check for any security advisories

2. **@nestjs-modules/mailer**: Visit https://www.npmjs.com/package/@nestjs-modules/mailer
   - Verify publisher is nest-modules org
   - Verify recent publish is routine
   - Check for any security advisories

3. **nodemailer**: Visit https://www.npmjs.com/package/nodemailer
   - Verify publisher is andris (Andris Reinman, original author)
   - Verify recent publish is routine
   - Check for any security advisories
Type "approved" to proceed with installation, or describe any concerns found Task 2: MailModule, password reset flow, force-change interceptor, and frontend pages - apps/api/src/auth/auth.service.ts (existing auth service from Plan 02-01) - apps/api/src/auth/auth.controller.ts (existing auth controller) - apps/api/src/app.module.ts (current module configuration) - apps/api/prisma/schema.prisma (PasswordResetToken model) - apps/web/src/app/(auth)/layout.tsx (auth layout from Plan 02-02) - .planning/phases/02-authentication-multi-tenancy/02-RESEARCH.md (SMTP ENV vars, security domain, Pitfall 5) apps/api/src/mail/mail.module.ts apps/api/src/mail/mail.service.ts apps/api/src/auth/auth.service.ts apps/api/src/auth/auth.controller.ts apps/api/src/auth/dto/reset-password.dto.ts apps/api/src/auth/dto/change-password.dto.ts apps/api/src/auth/interceptors/force-password-change.interceptor.ts apps/api/src/app.module.ts apps/api/package.json apps/web/src/app/(auth)/reset-password/page.tsx apps/web/src/app/(auth)/reset-password/[token]/page.tsx apps/web/src/app/(portal)/change-password/page.tsx apps/web/src/messages/de.json apps/web/src/messages/en.json docker-compose.yml docker-compose.dev.yml First, install the SUS-flagged packages (approved by human in Task 1): cd apps/api && pnpm add @nestjs-modules/mailer nodemailer ldapts cd apps/api && pnpm add -D @types/nodemailer
Add MailHog service to docker-compose.dev.yml for development email testing:
mailhog service using mailhog/mailhog image, ports 1025:1025 (SMTP) and 8025:8025 (web UI), on backend-net.
Add TESSERA_SMTP_HOST, TESSERA_SMTP_PORT, TESSERA_SMTP_USER, TESSERA_SMTP_PASSWORD, TESSERA_SMTP_FROM, TESSERA_APP_URL env vars to docker-compose.yml api service with defaults pointing to MailHog: host=mailhog, port=1025, user empty, password empty, from="Tessera <tessera@tessera.local>", app_url=http://localhost:3000.

Create apps/api/src/mail/mail.module.ts:
- Import MailerModule.forRootAsync from @nestjs-modules/mailer
- Configure with useFactory reading TESSERA_SMTP_HOST, TESSERA_SMTP_PORT, TESSERA_SMTP_SECURE, TESSERA_SMTP_USER, TESSERA_SMTP_PASSWORD from ConfigService
- Set defaults.from from TESSERA_SMTP_FROM env var
- Export the module globally

Create apps/api/src/mail/mail.service.ts:
- Injectable service wrapping MailerService from @nestjs-modules/mailer
- Method sendPasswordResetEmail(email, token, locale): Compose email with subject (i18n-aware: "Passwort zuruecksetzen" / "Reset your password"), body containing the reset link: TESSERA_APP_URL/reset-password/TOKEN. Use plain text email (no HTML template needed for MVP). Include expiry notice (1 hour).
- Method sendWelcomeEmail(email, username, locale): Optional welcome email for newly created users.

Create apps/api/src/auth/dto/reset-password.dto.ts:
- RequestResetDto: email (@IsEmail, @IsNotEmpty)
- ResetPasswordDto: token (@IsString, @IsNotEmpty), newPassword (@IsString, @MinLength(8))

Create apps/api/src/auth/dto/change-password.dto.ts:
- ChangePasswordDto: currentPassword (@IsString), newPassword (@IsString, @MinLength(8))

Extend apps/api/src/auth/auth.service.ts with new methods:
- requestPasswordReset(email): Find user by email. If not found, return success anyway (prevent email enumeration). Generate a random UUID token, create PasswordResetToken record with 1-hour expiry. Send email via MailService. Per D-03 self-service reset.
- resetPassword(token, newPassword): Find PasswordResetToken by token. Validate not expired and not used. Hash new password with argon2, update user. Mark token as used (set usedAt). Set mustChangePassword to false.
- changePassword(userId, currentPassword, newPassword): Verify current password with argon2. Hash new password. Update user. Set mustChangePassword to false.
- adminResetPassword(userId, newPassword): Per D-03 admin reset. Hash new password, update user, optionally set mustChangePassword to true.

Extend apps/api/src/auth/auth.controller.ts with new endpoints:
- POST /auth/request-reset: @Public(). Body: RequestResetDto. Calls requestPasswordReset. Always returns 200 (no email enumeration).
- POST /auth/reset-password: @Public(). Body: ResetPasswordDto. Calls resetPassword. Returns success or error.
- POST /auth/change-password: Body: ChangePasswordDto + @CurrentUser. Calls changePassword.
- POST /auth/admin-reset-password/:userId: @Roles(Role.ADMIN, Role.SUPER_ADMIN) @UseGuards(RolesGuard). Calls adminResetPassword.

Create apps/api/src/auth/interceptors/force-password-change.interceptor.ts per Pitfall 5:
- Global NestJS interceptor implementing NestInterceptor
- In intercept(): Check if request.user exists and user.mustChangePassword is true
- If true AND the current route is NOT /auth/change-password and NOT /auth/logout, throw ForbiddenException with body { statusCode: 403, message: 'FORCE_PASSWORD_CHANGE', error: 'Must change password before continuing' }
- Register as global APP_INTERCEPTOR in app.module.ts

Update app.module.ts: Import MailModule. Register ForcePasswordChangeInterceptor as global APP_INTERCEPTOR.

Create apps/web/src/app/(auth)/reset-password/page.tsx:
- 'use client' form page in the (auth) route group (no sidebar/header per D-04)
- Simple form with email input and submit button
- On submit, POST to /auth/request-reset
- Show success message: "Falls ein Konto mit dieser E-Mail existiert, wurde ein Link gesendet" (prevents email enumeration)
- Link back to /login
- All strings via useTranslations('auth')

Create apps/web/src/app/(auth)/reset-password/[token]/page.tsx:
- 'use client' form page
- Form with new password and confirm password fields
- On submit, POST to /auth/reset-password with token from URL params and new password
- Show success or error message
- On success, redirect to /login after 3 seconds
- All strings via useTranslations('auth')

Create apps/web/src/app/(portal)/change-password/page.tsx:
- 'use client' form page inside (portal) route group (has sidebar/header)
- Form with current password, new password, confirm password
- On submit, POST to /auth/change-password
- On success, update auth store (clear mustChangePassword) and redirect to dashboard
- All strings via useTranslations('auth')

Add i18n keys to de.json and en.json:
- auth.resetPassword.title, auth.resetPassword.email, auth.resetPassword.submit, auth.resetPassword.success, auth.resetPassword.newPassword, auth.resetPassword.confirmPassword, auth.resetPassword.tokenExpired, auth.resetPassword.tokenInvalid, auth.resetPassword.success
- auth.changePassword.title, auth.changePassword.currentPassword, auth.changePassword.newPassword, auth.changePassword.confirmPassword, auth.changePassword.submit, auth.changePassword.success, auth.changePassword.forceChangeNotice
- auth.forgotPassword (link text on login page)
cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check - ldapts, @nestjs-modules/mailer, and nodemailer installed in apps/api/package.json - MailModule configures SMTP from ENV variables - POST /auth/request-reset sends password reset email (testable via MailHog) - POST /auth/reset-password validates token and resets password - POST /auth/change-password validates current password and changes to new - POST /auth/admin-reset-password/:userId allows ADMIN/SUPER_ADMIN to reset user passwords per D-03 - ForcePasswordChangeInterceptor returns 403 FORCE_PASSWORD_CHANGE on all routes except change-password and logout per D-06 - MailHog service added to docker-compose.dev.yml on port 8025 - Reset password page exists at /reset-password with email form - Token reset page exists at /reset-password/[token] with new password form - Change password page exists at /change-password with current + new password form - All strings internationalized in de.json and en.json - Type-check passes for all packages Password reset via email works end-to-end (D-03), admin can manually reset passwords (D-03), force-password-change interceptor enforces D-06, MailHog available for dev email testing.

<threat_model>

Trust Boundaries

Boundary Description
Email link -> reset form Token in URL must be validated server-side
Public reset endpoint Must not reveal whether email exists (enumeration prevention)
npm registry -> project SUS packages verified by human before install

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-12 Information Disclosure /auth/request-reset mitigate Always return 200 regardless of email existence; prevents enumeration
T-02-13 Repudiation Password reset token mitigate Single-use token (mark usedAt after use); 1-hour expiry; UUID token not guessable
T-02-14 Elevation of Privilege Force password change bypass mitigate Global interceptor on ALL routes except /auth/change-password and /auth/logout per Pitfall 5
T-02-15 Tampering Admin password reset mitigate Only ADMIN/SUPER_ADMIN via RolesGuard; ADMIN cannot reset SUPER_ADMIN passwords
T-02-SC Tampering npm installs (ldapts, mailer, nodemailer) mitigate Blocking human checkpoint verifies package legitimacy before installation
</threat_model>
After both tasks complete: 1. docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d 2. Login as admin, navigate to /change-password, change password -- should succeed 3. With TESSERA_FORCE_CHANGE=true, new login should redirect to /change-password 4. Request password reset via /reset-password, check MailHog at http://localhost:8025 for email 5. Click reset link in email, set new password, login with new password 6. Admin resets another user's password via API 7. pnpm turbo type-check passes

<success_criteria>

  • SUS packages verified and installed (supply chain security gate passed)
  • Password reset via email works end-to-end with MailHog in dev (D-03)
  • Admin can manually reset user passwords (D-03)
  • Force-password-change interceptor blocks all routes until password changed (D-06)
  • Reset tokens are single-use with 1-hour expiry
  • No email enumeration possible via reset endpoint </success_criteria>
Create `.planning/phases/02-authentication-multi-tenancy/02-03-SUMMARY.md` when done