clientIp was part of the public signInInputSchema, so any client could spoof its own rate-limit bucket or dodge IP throttling entirely (audit finding B2). The schema no longer carries it (strict parsing rejects it with BAD_REQUEST); instead the web-next tRPC fetch adapter derives it in createTrpcContext from x-forwarded-for (first hop) / x-real-ip — trust caveat documented — and the router threads ctx.clientIp to the controller as a second, server-only argument typed outside the input schema (SignInRequestContext). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import type { IRateLimit } from "@repo/core-shared/rate-limit";
|
|
import {
|
|
AuthenticationError,
|
|
TooManyRequestsError,
|
|
} from "../../entities/errors/auth";
|
|
import { cookieSchema } from "../../entities/models/cookie";
|
|
import { sessionSchema } from "../../entities/models/session";
|
|
import type { IUsersRepository } from "../repositories/users.repository.interface";
|
|
import type { IAuthenticationService } from "../services/authentication.service.interface";
|
|
|
|
// ── Input ────────────────────────────────────────────────────────────────
|
|
// `.strict()` + no clientIp field: a client submitting clientIp is rejected
|
|
// at the procedure boundary (audit finding B2).
|
|
export const signInInputSchema = z
|
|
.object({
|
|
username: z.string().min(3).max(31),
|
|
password: z.string().min(6).max(255),
|
|
})
|
|
.strict();
|
|
export type SignInInput = z.infer<typeof signInInputSchema>;
|
|
|
|
/**
|
|
* Server-derived per-request context, typed OUTSIDE the public input schema
|
|
* so it can never be client-supplied (audit finding B2). The tRPC adapter
|
|
* derives `clientIp` from trusted proxy headers and the controller threads
|
|
* it through; `undefined` means "no proxy header present" and falls into a
|
|
* shared bucket.
|
|
*/
|
|
export type SignInRequestContext = {
|
|
clientIp?: string;
|
|
};
|
|
|
|
// ── Output ───────────────────────────────────────────────────────────────
|
|
export const signInOutputSchema = z.object({
|
|
session: sessionSchema,
|
|
cookie: cookieSchema,
|
|
});
|
|
export type SignInOutput = z.infer<typeof signInOutputSchema>;
|
|
|
|
// ── Use case ─────────────────────────────────────────────────────────────
|
|
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
|
|
|
|
export const signInUseCase =
|
|
(
|
|
usersRepository: IUsersRepository,
|
|
authenticationService: IAuthenticationService,
|
|
rateLimit: IRateLimit,
|
|
) =>
|
|
async (input: SignInInput & SignInRequestContext): Promise<SignInOutput> => {
|
|
const { allowed: ipAllowed } = await rateLimit.consume(
|
|
"ip",
|
|
`signIn:ip:${input.clientIp ?? ""}`,
|
|
);
|
|
if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts");
|
|
|
|
const { allowed: accountAllowed } = await rateLimit.consume(
|
|
"account",
|
|
`signIn:account:${input.username}`,
|
|
);
|
|
if (!accountAllowed)
|
|
throw new TooManyRequestsError("Too many sign-in attempts");
|
|
|
|
const existingUser = await usersRepository.getUserByUsername(
|
|
input.username,
|
|
);
|
|
if (!existingUser) {
|
|
throw new AuthenticationError("User does not exist");
|
|
}
|
|
const validPassword = await authenticationService.verifyPassword(
|
|
existingUser.passwordHash,
|
|
input.password,
|
|
);
|
|
if (!validPassword) {
|
|
throw new AuthenticationError("Incorrect username or password");
|
|
}
|
|
const result = await authenticationService.createSession(existingUser);
|
|
return signInOutputSchema.parse(result);
|
|
};
|