Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ apps/api/uploads/

# Local git worktrees for parallel agent work — never commit
.worktrees/
.claude/worktrees/
18 changes: 18 additions & 0 deletions apps/api/drizzle/0059_password_setup_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Tokens de «crea tu contraseña» para perfiles sin contraseña (#204, parte de
-- #168): un donante que pre-registra sin cuenta recibe un enlace de un solo uso
-- y con caducidad para establecer su contraseña. Se guarda SOLO el hash SHA-256
-- del token (nunca el valor en claro), único para que la búsqueda sea una
-- igualdad indexada. Un token se consume marcando used_at; es válido mientras
-- used_at IS NULL AND expires_at > now(). FK en cascada: al borrar el usuario
-- desaparecen sus tokens.
CREATE TABLE IF NOT EXISTS password_setup_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS password_setup_tokens_user_idx
ON password_setup_tokens (user_id);
109 changes: 109 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,72 @@
]
}
},
"/auth/set-password": {
"post": {
"operationId": "AuthController_setPasswordRoute",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetPasswordDto"
}
}
}
},
"responses": {
"200": {
"description": "Contraseña establecida",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetPasswordResponseDto"
}
}
}
},
"400": {
"description": "Token inválido, caducado o ya usado"
},
"429": {
"description": "Rate limit exceeded — try again later"
}
},
"summary": "Establecer la contraseña de un perfil sin contraseña con un token de un solo uso (auto-login: devuelve un JWT)",
"tags": [
"auth"
]
}
},
"/auth/set-password/request": {
"post": {
"operationId": "AuthController_requestPasswordSetupRoute",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestPasswordSetupDto"
}
}
}
},
"responses": {
"202": {
"description": "Solicitud aceptada (se envía el email si procede)"
},
"429": {
"description": "Rate limit exceeded — try again later"
}
},
"summary": "Reenviar el email de «crea tu contraseña» a un perfil sin contraseña. Responde 202 siempre (no revela si el email existe).",
"tags": [
"auth"
]
}
},
"/auth/onboarding": {
"post": {
"operationId": "AuthController_onboardingRoute",
Expand Down Expand Up @@ -9189,6 +9255,49 @@
"accessToken"
]
},
"SetPasswordDto": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "Token de un solo uso recibido en el email de set-password"
},
"password": {
"type": "string",
"example": "mi-nueva-contrasena",
"minLength": 8
}
},
"required": [
"token",
"password"
]
},
"SetPasswordResponseDto": {
"type": "object",
"properties": {
"accessToken": {
"type": "string",
"description": "JWT access token (auto-login tras establecer la contraseña)"
}
},
"required": [
"accessToken"
]
},
"RequestPasswordSetupDto": {
"type": "object",
"properties": {
"email": {
"type": "string",
"example": "donor@example.com",
"description": "Email del perfil sin contraseña al que reenviar el enlace de set-password. La respuesta es 202 siempre, exista o no la cuenta."
}
},
"required": [
"email"
]
},
"OnboardingDto": {
"type": "object",
"properties": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { RequestPasswordSetup } from './request-password-setup';
import { InMemoryUserRepository } from '../infrastructure/in-memory-user.repository';
import {
SetPasswordInvite,
SetPasswordInviter,
} from '../domain/ports/set-password-inviter';
import { User } from '../domain/user';
import { UserId } from '../domain/user-id';
import { Email } from '../domain/email';

class RecordingInviter implements SetPasswordInviter {
readonly invites: SetPasswordInvite[] = [];
invite(invite: SetPasswordInvite): Promise<void> {
this.invites.push(invite);
return Promise.resolve();
}
}

describe('RequestPasswordSetup', () => {
let users: InMemoryUserRepository;
let inviter: RecordingInviter;
let request: RequestPasswordSetup;

beforeEach(() => {
users = new InMemoryUserRepository();
inviter = new RecordingInviter();
request = new RequestPasswordSetup(users, inviter);
});

async function saveUser(email: string, passwordHash: string | null) {
await users.save(
User.create({
id: UserId.create(),
email: Email.fromString(email),
passwordHash,
name: 'Donante',
isAdmin: false,
}),
);
}

it('invites a passwordless profile', async () => {
await saveUser('donor@example.com', null);
await request.execute({ email: 'donor@example.com' });
expect(inviter.invites).toHaveLength(1);
expect(inviter.invites[0].email).toBe('donor@example.com');
});

it('does not invite an account that already has a password', async () => {
await saveUser('member@example.com', 'existing-hash');
await request.execute({ email: 'member@example.com' });
expect(inviter.invites).toHaveLength(0);
});

it('is a silent no-op for an unknown email (no enumeration signal)', async () => {
await request.execute({ email: 'nobody@example.com' });
expect(inviter.invites).toHaveLength(0);
});

it('is a silent no-op for a malformed email', async () => {
await request.execute({ email: 'not-an-email' });
expect(inviter.invites).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { UserRepository } from '../domain/ports/user.repository';
import { SetPasswordInviter } from '../domain/ports/set-password-inviter';
import { Email } from '../domain/email';

export interface RequestPasswordSetupCommand {
email: string;
}

/**
* Resend the "create your password" email for a passwordless profile (#204).
*
* SECURITY: account-enumeration safe — it returns nothing and the caller always
* responds 202 regardless of outcome. An invite is only actually sent when the
* email maps to an existing account that has NO password yet (a passwordless
* donor profile). A malformed email, an unknown email, or an account that
* already has a password are all silent no-ops, so the endpoint can't be used to
* discover which emails exist or to spam a real user with reset links.
*/
export class RequestPasswordSetup {
constructor(
private readonly users: UserRepository,
private readonly inviter: SetPasswordInviter,
) {}

async execute(cmd: RequestPasswordSetupCommand): Promise<void> {
let email: Email;
try {
email = Email.fromString(cmd.email);
} catch {
return;
}

const user = await this.users.findByEmail(email);
if (!user || user.passwordHash !== null) return;

await this.inviter.invite({
userId: user.id.value,
email: user.email.value,
name: user.name,
});
}
}
126 changes: 126 additions & 0 deletions apps/api/src/contexts/identity/application/set-password.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { SetPassword } from './set-password';
import { InMemoryUserRepository } from '../infrastructure/in-memory-user.repository';
import { InMemoryPasswordSetupTokenRepository } from '../infrastructure/in-memory-password-setup-token.repository';
import { PasswordHasher } from '../domain/ports/password-hasher';
import { TokenService, TokenPayload } from '../domain/ports/token.service';
import { PasswordSetupToken } from '../domain/password-setup-token';
import {
generateSetupToken,
hashSetupToken,
} from '../domain/password-setup-token-generator';
import { User } from '../domain/user';
import { UserId } from '../domain/user-id';
import { Email } from '../domain/email';
import { InvalidPasswordSetupTokenError } from '../domain/invalid-password-setup-token.error';

class FakeHasher implements PasswordHasher {
hash(plain: string): Promise<string> {
return Promise.resolve(`hashed:${plain}`);
}
compare(plain: string, hash: string): Promise<boolean> {
return Promise.resolve(hash === `hashed:${plain}`);
}
}

class FakeTokenService implements TokenService {
sign(payload: TokenPayload): string {
return `jwt:${payload.sub}`;
}
verify(): TokenPayload {
throw new Error('not used');
}
}

const USER_ID = '22222222-2222-4222-8222-222222222222';

describe('SetPassword', () => {
let users: InMemoryUserRepository;
let tokens: InMemoryPasswordSetupTokenRepository;
let setPassword: SetPassword;

beforeEach(async () => {
users = new InMemoryUserRepository();
tokens = new InMemoryPasswordSetupTokenRepository();
setPassword = new SetPassword(
tokens,
users,
new FakeHasher(),
new FakeTokenService(),
);
await users.save(
User.create({
id: UserId.fromString(USER_ID),
email: Email.fromString('donor@example.com'),
passwordHash: null,
name: 'Donante',
isAdmin: false,
}),
);
});

async function issueToken(overrides?: {
plaintext?: string;
expiresAt?: Date;
used?: boolean;
}): Promise<string> {
const { plaintext, hash } = overrides?.plaintext
? {
plaintext: overrides.plaintext,
hash: hashSetupToken(overrides.plaintext),
}
: generateSetupToken();
let token = PasswordSetupToken.issue({
id: '11111111-1111-4111-8111-111111111111',
userId: USER_ID,
tokenHash: hash,
expiresAt: overrides?.expiresAt ?? new Date(Date.now() + 3_600_000),
});
if (overrides?.used) token = token.markUsed(new Date());
await tokens.save(token);
return plaintext;
}

it('sets the password with a valid token and returns a JWT', async () => {
const plaintext = await issueToken();

const res = await setPassword.execute({
token: plaintext,
newPassword: 'my-new-pass',
});

expect(res.accessToken).toBe(`jwt:${USER_ID}`);
const user = await users.findById(UserId.fromString(USER_ID));
expect(user!.passwordHash).toBe('hashed:my-new-pass');
});

it('invalidates the token after use (no replay)', async () => {
const plaintext = await issueToken();
await setPassword.execute({ token: plaintext, newPassword: 'first-pass' });

await expect(
setPassword.execute({ token: plaintext, newPassword: 'second-pass' }),
).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError);
});

it('rejects an expired token', async () => {
const plaintext = await issueToken({
expiresAt: new Date(Date.now() - 1_000),
});
await expect(
setPassword.execute({ token: plaintext, newPassword: 'x' }),
).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError);
});

it('rejects an already-used token', async () => {
const plaintext = await issueToken({ used: true });
await expect(
setPassword.execute({ token: plaintext, newPassword: 'x' }),
).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError);
});

it('rejects an unknown token', async () => {
await expect(
setPassword.execute({ token: 'not-a-real-token', newPassword: 'x' }),
).rejects.toBeInstanceOf(InvalidPasswordSetupTokenError);
});
});
Loading
Loading