Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;

-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash");

-- CreateIndex
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");

-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

28 changes: 28 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ model User {
// refresh-token revocation list (issue #18) — this is the narrow "revoke
// everything for one account" case that deactivation needs.
sessionsInvalidatedAt DateTime?
// Per-user refresh-token version. Embedded in every access/refresh token
// as `tv` and compared against this column at refresh time. Bumping it
// (password reset, Phase 8 #33) invalidates every outstanding refresh
// token for the user, since none of them carry the new value — the
// interim kill switch issue #33 asked for while #18 doesn't exist yet.
tokenVersion Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -133,6 +139,7 @@ model User {
refreshSessions RefreshSession[]

emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
adminInvitesSent AdminInvite[]
// Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer).
// Opposite of AccessLog.accessedByUser.
Expand All @@ -158,6 +165,27 @@ model EmailVerificationToken {
@@index([userId])
}

// Password reset tokens (Phase 8 #33). Only the SHA-256 hash of the
// token is ever stored — the raw token exists exactly once, in the
// notification that carries the reset link, and is never logged or
// persisted. Single-use (`usedAt`) and expiring (`expiresAt`).
model PasswordResetToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// SHA-256 hex of the raw token. Unique so lookup-by-hash is a single
// indexed read, and a duplicate hash (astronomically unlikely) is a
// constraint violation rather than a silent overwrite.
tokenHash String @unique
expiresAt DateTime
// Non-null once used or superseded by a newer request — either way the
// token is dead. Requesting a second reset marks the first one used.
usedAt DateTime?
createdAt DateTime @default(now())

@@index([userId])
}

model AdminInvite {
id String @id @default(uuid())
email String
Expand Down
15 changes: 15 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ export const config = {
accessTtl: '15m',
refreshTtl: '30d',
},
// Base URL of docforum-web, used to build the link in outbound
// notifications (password reset today). Not a provider decision — see
// issue #17 — just the URL the recipient is sent to.
appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:5173',
passwordReset: {
// Raw tokens are single-use and short-lived; only their SHA-256 hash
// is stored (see PasswordResetToken in schema.prisma).
tokenTtlMinutes: Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES ?? 30),
// Rate limits on POST /auth/password-reset/request, so the endpoint
// can't be used to spam an inbox. Applied per email AND per IP —
// either limit being hit returns 429. See auth/services/rate-limit.ts
// for the (in-memory, single-process) caveat.
requestsPerEmailPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_EMAIL_PER_HOUR ?? 3),
requestsPerIpPerHour: Number(process.env.PASSWORD_RESET_REQUESTS_PER_IP_PER_HOUR ?? 10),
},
// Phase 8 hardening knobs (issues #34/#35/#37). Env-configurable so a
// preview deployment can be tightened without a code change, and so
// tests can set short windows instead of waiting real minutes.
Expand Down
24 changes: 24 additions & 0 deletions backend/src/modules/auth/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as authService from '../services';
import {
clearLoginAttemptsSchema,
loginSchema,
passwordResetConfirmSchema,
passwordResetRequestSchema,
signupSchema,
signupWithInviteSchema,
verifyEmailSchema,
Expand Down Expand Up @@ -145,6 +147,28 @@ export function logout(req: Request, res: Response) {
res.status(204).send();
}

// Both reset endpoints answer identically for known and unknown emails —
// same status, same body, no branch on the result of the lookup. Rate
// limiting is the one intentional exception (429), and it fires before any
// account lookup, so it can't leak existence either.
export async function requestPasswordReset(req: Request, res: Response) {
const { email } = passwordResetRequestSchema.parse(req.body);
await authService.requestPasswordReset({ email, ip: req.ip ?? 'unknown' });
res.status(202).json({
message: 'If an account exists for that email, a password reset link has been sent.',
});
}

export async function confirmPasswordReset(req: Request, res: Response) {
const input = passwordResetConfirmSchema.parse(req.body);
await authService.confirmPasswordReset(input);
// The reset already killed every refresh token for this user
// (tokenVersion bump) — clear the caller's cookie too so a browser that
// still holds the old one isn't left sending a dead token.
res.clearCookie(REFRESH_COOKIE, config.cookie);
res.status(200).json({ message: 'Password updated. Please sign in with your new password.' });
}

// --- Session management (Phase 8 #38) -----------------------------------

function refreshCookieValue(req: Request): string | undefined {
Expand Down
61 changes: 58 additions & 3 deletions backend/src/modules/auth/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Auth module's repository — the only place that touches the `User` /
// `PatientProfile` / `DoctorProfile` tables for auth purposes. Other
// modules must go through this module's services, never import this file
// directly (AGENTS.md hard rule 4).
// `PatientProfile` / `DoctorProfile` / `PasswordResetToken` tables for auth
// purposes. Other modules must go through this module's services, never
// import this file directly (AGENTS.md hard rule 4).
import { prisma } from '../../../db/prisma-client';
import type { Role } from '@prisma/client';

Expand Down Expand Up @@ -230,3 +230,58 @@ export function clearLoginAttempts(buckets: { scope: string; key: string }[]) {
export function pruneStaleLoginAttempts(before: Date) {
return prisma.loginAttempt.deleteMany({ where: { lastFailedAt: { lt: before } } }).then((result) => result.count);
}

/**
* Stores a new single-use reset token for a user, superseding any of that
* user's outstanding ones in the same transaction — "requesting a second
* reset invalidates the first token" is a DB-level guarantee here, not an
* application-level hope.
*/
export function createPasswordResetToken(input: {
userId: string;
tokenHash: string;
expiresAt: Date;
}) {
return prisma.$transaction(async (tx) => {
await tx.passwordResetToken.updateMany({
where: { userId: input.userId, usedAt: null },
data: { usedAt: new Date() },
});
return tx.passwordResetToken.create({
data: { userId: input.userId, tokenHash: input.tokenHash, expiresAt: input.expiresAt },
});
});
}

/** Lookup by token hash (unique index) — never by the raw token, which is
* never stored. */
export function findPasswordResetTokenByHash(tokenHash: string) {
return prisma.passwordResetToken.findUnique({ where: { tokenHash } });
}

/**
* Consumes a reset token and applies the new password in one transaction:
* marks the token used, sets the new password hash, and increments
* `User.tokenVersion` so every previously issued refresh token for this
* user stops working (issue #33; the full revocation list is #18).
*/
export function consumePasswordResetToken(input: {
tokenId: string;
userId: string;
passwordHash: string;
}) {
return prisma.$transaction(async (tx) => {
const claimed = await tx.passwordResetToken.updateMany({
where: { id: input.tokenId, usedAt: null },
data: { usedAt: new Date() },
});
// Two concurrent confirms of the same token: only one claims the row.
if (claimed.count !== 1) return false;

await tx.user.update({
where: { id: input.userId },
data: { passwordHash: input.passwordHash, tokenVersion: { increment: 1 } },
});
return true;
});
}
5 changes: 5 additions & 0 deletions backend/src/modules/auth/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ authRouter.post('/login-attempts/clear', authenticate, requireRole('admin'), aut
authRouter.post('/refresh', requireAllowedOrigin, authController.refresh);
authRouter.post('/logout', requireAllowedOrigin, authController.logout);

// Password reset (Phase 8 #33) — both unauthenticated; possession of the
// emailed single-use token is the credential for the confirm step.
authRouter.post('/password-reset/request', authController.requestPasswordReset);
authRouter.post('/password-reset/confirm', authController.confirmPasswordReset);

// Session management (Phase 8 #38). Note the ordering: `DELETE /sessions`
// (revoke all others) is registered before `DELETE /sessions/:id` — Express
// matches in registration order, and `/sessions` must not be read as an id.
Expand Down
27 changes: 24 additions & 3 deletions backend/src/modules/auth/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ import type { PatientExportSection } from '../../patients/services';
export { verifyAccessToken } from './tokens';
export type { AccessTokenPayload } from './tokens';

// Password reset (Phase 8 #33) lives in its own file; re-exported here so
// controllers keep calling a single service surface.
export {
confirmPasswordReset,
hashResetToken,
requestPasswordReset,
resetPasswordResetRateLimits,
type ConfirmPasswordResetInput,
type RequestPasswordResetInput,
} from './password-reset';

export {
assertSessionUsable,
listSessions,
Expand Down Expand Up @@ -105,10 +116,10 @@ function toPublicUser(user: {
}

function issueTokensForSession(
user: { id: string; role: Role },
user: { id: string; role: Role; tokenVersion?: number },
sessionId: string,
): { accessToken: string; refreshToken: string; csrfToken: string } {
const payload = { sub: user.id, role: user.role };
const payload = { sub: user.id, role: user.role, tv: user.tokenVersion ?? 0 };
// A fresh CSRF token per session, and per rotation — bound into the
// refresh token so verifying it needs no server-side state (issue #37).
const csrfToken = generateCsrfToken();
Expand All @@ -124,7 +135,7 @@ function issueTokensForSession(

/** Starts a new session (one per login) and issues its first token pair. */
async function issueNewSession(
user: { id: string; role: Role },
user: { id: string; role: Role; tokenVersion?: number },
context: SessionContext,
): Promise<{ accessToken: string; refreshToken: string; csrfToken: string; sessionId: string }> {
const session = await startSession({ userId: user.id, ...context });
Expand Down Expand Up @@ -397,6 +408,16 @@ export async function refresh(refreshToken: string, csrfToken?: string | null):
// session revoked by deactivation/reactivation can't come back to life
// through a refresh cookie either (Phase 8, issue #39).
if (user.deactivatedAt) throw new HttpError(403, 'This account has been deactivated.');

// `tv` check: a password reset (or anything else that bumps
// `User.tokenVersion`) kills every refresh token issued before it. Tokens
// predating this field carry no `tv` and read as version 0, which is
// exactly right — they stay valid only while the user has never had a
// version bump.
if ((payload.tv ?? 0) !== user.tokenVersion) {
throw new HttpError(401, 'Invalid or expired refresh token.');
}

if (isSessionRevoked(user.sessionsInvalidatedAt, payload.iat)) {
throw new HttpError(401, 'Your session has been revoked. Please sign in again.');
}
Expand Down
110 changes: 110 additions & 0 deletions backend/src/modules/auth/services/password-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Password reset flow (issue #33). Two endpoints, deliberately paired:
// - request: always behaves the same whether or not the email exists.
// - confirm: single-use, expiring, hashed token → new password + every
// outstanding refresh token invalidated.
//
// Security notes enforced here, not just in the controller:
// - The raw token only ever exists in memory (and in the outbound
// notification). The DB stores its SHA-256 hash.
// - Unknown/expired/reused/superseded tokens all produce the *same*
// generic error, and the hash comparison is constant-time.
// - Rate-limited per email and per IP before any account lookup.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { config } from '../../../config';
import { HttpError } from '../../../http-error';
import { sendPasswordResetLink } from '../../notifications/services';
import { hashPassword } from './password';
import {
consumePasswordResetToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
findUserByEmail,
} from '../repositories';
import { createRateLimiter } from './rate-limit';

const HOUR_MS = 60 * 60 * 1000;

/** Deliberately 400, not 404/410 — the client must not be able to tell
* which of "no such token", "expired", "already used" or "superseded by a
* newer request" happened. */
const GENERIC_INVALID_TOKEN_MESSAGE =
'This password reset link is invalid or has expired. Request a new one.';

const emailLimiter = createRateLimiter(config.passwordReset.requestsPerEmailPerHour, HOUR_MS);
const ipLimiter = createRateLimiter(config.passwordReset.requestsPerIpPerHour, HOUR_MS);

export function hashResetToken(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}

/** Constant-time comparison of two hex-encoded hashes. */
function hashesEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

export interface RequestPasswordResetInput {
email: string;
/** Coarse source address for the per-IP limit. Never persisted. */
ip: string;
}

/**
* Issues a reset token if the email belongs to an account. Always resolves
* — the caller responds 202 either way, so response *and* status are
* identical for known and unknown addresses.
*/
export async function requestPasswordReset(input: RequestPasswordResetInput): Promise<void> {
if (!emailLimiter.consume(input.email.toLowerCase()) || !ipLimiter.consume(input.ip)) {
throw new HttpError(429, 'Too many password reset requests. Please try again later.');
}

const user = await findUserByEmail(input.email);
if (!user) {
// Do the same shape of work for an unknown address so timing doesn't
// leak existence: hash a throwaway token, then drop it.
hashResetToken(randomBytes(32).toString('base64url'));
return;
}

const rawToken = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + config.passwordReset.tokenTtlMinutes * 60_000);

await createPasswordResetToken({ userId: user.id, tokenHash: hashResetToken(rawToken), expiresAt });
await sendPasswordResetLink({ email: user.email, token: rawToken, expiresAt });
}

export interface ConfirmPasswordResetInput {
token: string;
newPassword: string;
}

/** Applies a new password for a valid, unexpired, unused token. */
export async function confirmPasswordReset(input: ConfirmPasswordResetInput): Promise<void> {
const presentedHash = hashResetToken(input.token);
const record = await findPasswordResetTokenByHash(presentedHash);

if (!record || !hashesEqual(record.tokenHash, presentedHash)) {
throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE);
}
if (record.usedAt) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE);
if (record.expiresAt.getTime() <= Date.now()) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE);

const passwordHash = await hashPassword(input.newPassword);
const consumed = await consumePasswordResetToken({
tokenId: record.id,
userId: record.userId,
passwordHash,
});
// Lost the race to a concurrent confirm of the same token.
if (!consumed) throw new HttpError(400, GENERIC_INVALID_TOKEN_MESSAGE);
}

/** Test seam only — the limiters are module-level singletons so the limits
* actually hold across requests. */
export function resetPasswordResetRateLimits(): void {
emailLimiter.reset();
ipLimiter.reset();
}
Loading
Loading