Stage M2
This commit is contained in:
@@ -40,7 +40,7 @@ STORAGE_PUBLIC_URL=http://localhost:9000/sport-media
|
||||
|
||||
# --- Rate limiting ----------------------------------------------------------
|
||||
RATE_LIMIT_TTL_SECONDS=60
|
||||
RATE_LIMIT_MAX=120
|
||||
RATE_LIMIT_MAX=300
|
||||
|
||||
# --- Observability ----------------------------------------------------------
|
||||
LOG_LEVEL=debug
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:reset": "prisma migrate reset --force"
|
||||
"db:reset": "prisma migrate reset --force",
|
||||
"create-admin": "tsx prisma/create-admin.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
@@ -37,6 +38,7 @@
|
||||
"@sport/types": "workspace:*",
|
||||
"@sport/validation": "workspace:*",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
@@ -54,6 +56,7 @@
|
||||
"@sport/config": "workspace:*",
|
||||
"@sport/eslint-config": "workspace:*",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.19.0",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Creates or repairs a SUPER_ADMIN account.
|
||||
*
|
||||
* pnpm --filter @sport/api run create-admin
|
||||
* ADMIN_EMAIL=me@example.com ADMIN_PASSWORD='…' pnpm ... run create-admin
|
||||
*
|
||||
* This is the production bootstrap path, and the reason the seed never creates
|
||||
* a privileged account with a known password. Safe to re-run: an existing
|
||||
* account has its password reset and its role re-granted, which doubles as the
|
||||
* "locked out of the admin" recovery procedure.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { SYSTEM_ROLES } from '@sport/types';
|
||||
|
||||
import { generatePassword, hashPassword, verifyPassword } from './seed/accounts';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const email = (process.env['ADMIN_EMAIL'] ?? 'admin@sport.local').trim().toLowerCase();
|
||||
const provided = process.env['ADMIN_PASSWORD'];
|
||||
const password = provided ?? generatePassword();
|
||||
|
||||
if (provided && provided.length < 10) {
|
||||
throw new Error('ADMIN_PASSWORD must be at least 10 characters.');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// Verify the hash round-trips before writing it. A malformed hash here would
|
||||
// create an account nobody can ever sign in to, and the failure would only
|
||||
// surface at the login screen.
|
||||
if (!(await verifyPassword(password, passwordHash))) {
|
||||
throw new Error('Password hash failed self-verification; refusing to write.');
|
||||
}
|
||||
|
||||
const role = await prisma.role.findUnique({ where: { key: SYSTEM_ROLES.SUPER_ADMIN } });
|
||||
if (!role) {
|
||||
throw new Error('The super_admin role is missing. Run `pnpm db:seed` first.');
|
||||
}
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { passwordHash, status: 'ACTIVE', type: 'SUPER_ADMIN', deletedAt: null },
|
||||
create: {
|
||||
email,
|
||||
passwordHash,
|
||||
type: 'SUPER_ADMIN',
|
||||
status: 'ACTIVE',
|
||||
firstName: 'Super',
|
||||
lastName: 'Admin',
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId: user.id, roleId: role.id } },
|
||||
update: {},
|
||||
create: { userId: user.id, roleId: role.id },
|
||||
});
|
||||
|
||||
console.log('\nSuper admin ready.\n');
|
||||
console.log(` Email: ${email}`);
|
||||
|
||||
if (provided) {
|
||||
console.log(' Password: (from ADMIN_PASSWORD)');
|
||||
} else {
|
||||
console.log(` Password: ${password}`);
|
||||
console.log('\n Generated password — shown once. Store it now.');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
|
||||
import { seedDevAccounts } from './seed/accounts';
|
||||
import { seedCatalog } from './seed/catalog';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
@@ -144,6 +145,19 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await seedCatalog(prisma);
|
||||
|
||||
const accounts = await seedDevAccounts(prisma);
|
||||
const created = accounts.filter((account) => account.created);
|
||||
|
||||
if (created.length > 0) {
|
||||
console.log('\nDevelopment sign-in accounts (shown once):\n');
|
||||
for (const account of created) {
|
||||
console.log(` ${account.email.padEnd(24)} ${account.password} [${account.role}]`);
|
||||
}
|
||||
console.log('\n Development only. Use `pnpm db:create-admin` for real environments.\n');
|
||||
} else {
|
||||
console.log('Development accounts already exist; passwords left unchanged.');
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { SYSTEM_ROLES } from '@sport/types';
|
||||
|
||||
/**
|
||||
* Password hashing for scripts.
|
||||
*
|
||||
* Deliberately duplicated from `common/security/password.service.ts` rather
|
||||
* than imported: these scripts run under `tsx` outside the Nest container, and
|
||||
* booting the DI graph to hash one string would be the more fragile choice.
|
||||
*
|
||||
* The hash FORMAT is the contract between the two, and it is self-describing —
|
||||
* so a drift shows up as a failed login on the very next attempt, not as silent
|
||||
* corruption. If a third caller ever appears, extract it to a package.
|
||||
*/
|
||||
const PARAMS = { N: 16_384, r: 8, p: 1 } as const;
|
||||
const KEY_LENGTH = 64;
|
||||
const MAX_MEM = 64 * 1024 * 1024;
|
||||
|
||||
export function hashPassword(plaintext: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(
|
||||
plaintext.normalize('NFKC'),
|
||||
salt,
|
||||
KEY_LENGTH,
|
||||
{ ...PARAMS, maxmem: MAX_MEM },
|
||||
(error, derived) => {
|
||||
if (error) return reject(error);
|
||||
|
||||
resolve(
|
||||
[
|
||||
'scrypt',
|
||||
PARAMS.N,
|
||||
PARAMS.r,
|
||||
PARAMS.p,
|
||||
salt.toString('base64'),
|
||||
derived.toString('base64'),
|
||||
].join('$'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Used by the smoke test below to prove the format round-trips. */
|
||||
export function verifyPassword(plaintext: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return Promise.resolve(false);
|
||||
|
||||
const [, n, r, p, salt, hash] = parts;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
scrypt(
|
||||
plaintext.normalize('NFKC'),
|
||||
Buffer.from(salt ?? '', 'base64'),
|
||||
KEY_LENGTH,
|
||||
{ N: Number(n), r: Number(r), p: Number(p), maxmem: MAX_MEM },
|
||||
(error, derived) => {
|
||||
if (error) return resolve(false);
|
||||
|
||||
const expected = Buffer.from(hash ?? '', 'base64');
|
||||
resolve(derived.length === expected.length && timingSafeEqual(derived, expected));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** A readable, high-entropy password for generated accounts. */
|
||||
export function generatePassword(): string {
|
||||
// Base64url of 18 bytes ≈ 24 characters, ~144 bits. Suffixed to guarantee the
|
||||
// policy's uppercase/lowercase/digit requirements regardless of the draw.
|
||||
return `${randomBytes(18).toString('base64url')}aA1`;
|
||||
}
|
||||
|
||||
export interface SeededAccount {
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development sign-in accounts.
|
||||
*
|
||||
* Guarded twice — by NODE_ENV and by an explicit opt-out — because a known
|
||||
* password reaching production is the single worst thing a seed can do. The
|
||||
* generated password is printed once and never stored anywhere else.
|
||||
*
|
||||
* Existing accounts are left completely alone: re-running the seed must not
|
||||
* reset a password someone has already changed.
|
||||
*/
|
||||
export async function seedDevAccounts(prisma: PrismaClient): Promise<SeededAccount[]> {
|
||||
const accounts: SeededAccount[] = [];
|
||||
|
||||
const definitions = [
|
||||
{
|
||||
email: 'admin@sport.local',
|
||||
type: 'SUPER_ADMIN' as const,
|
||||
firstName: 'Demo',
|
||||
lastName: 'Admin',
|
||||
roleKey: SYSTEM_ROLES.SUPER_ADMIN,
|
||||
},
|
||||
{
|
||||
email: 'staff@sport.local',
|
||||
type: 'STAFF' as const,
|
||||
firstName: 'Demo',
|
||||
lastName: 'Staff',
|
||||
roleKey: SYSTEM_ROLES.CATALOG_MANAGER,
|
||||
},
|
||||
{
|
||||
email: 'customer@sport.local',
|
||||
type: 'CUSTOMER' as const,
|
||||
firstName: 'Demo',
|
||||
lastName: 'Customer',
|
||||
roleKey: SYSTEM_ROLES.CUSTOMER,
|
||||
},
|
||||
];
|
||||
|
||||
for (const definition of definitions) {
|
||||
const existing = await prisma.user.findUnique({ where: { email: definition.email } });
|
||||
|
||||
if (existing) {
|
||||
accounts.push({
|
||||
email: definition.email,
|
||||
password: '(unchanged)',
|
||||
role: definition.roleKey,
|
||||
created: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = generatePassword();
|
||||
const role = await prisma.role.findUnique({ where: { key: definition.roleKey } });
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: definition.email,
|
||||
passwordHash: await hashPassword(password),
|
||||
type: definition.type,
|
||||
status: 'ACTIVE',
|
||||
firstName: definition.firstName,
|
||||
lastName: definition.lastName,
|
||||
emailVerifiedAt: new Date(),
|
||||
...(role ? { roles: { create: { roleId: role.id } } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// A customer account needs its shopper profile, or /account has nothing to
|
||||
// hang addresses and orders off later.
|
||||
if (definition.type === 'CUSTOMER') {
|
||||
await prisma.customer.create({ data: { userId: user.id } });
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
email: definition.email,
|
||||
password,
|
||||
role: definition.roleKey,
|
||||
created: true,
|
||||
});
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { MediaUrlModule } from './common/media/media.module';
|
||||
import { SecurityModule } from './common/security/security.module';
|
||||
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
|
||||
import type { AppConfig } from './config/configuration';
|
||||
import { EventsModule } from './infrastructure/events/events.module';
|
||||
@@ -55,6 +56,7 @@ import { WishlistModule } from './modules/wishlist/wishlist.module';
|
||||
StorageModule,
|
||||
EventsModule,
|
||||
MediaUrlModule,
|
||||
SecurityModule,
|
||||
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [APP_CONFIG],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Hand-written rather than `promisify(scrypt)`: promisify resolves to the
|
||||
* no-options overload, so passing `maxmem` becomes a type error even though the
|
||||
* runtime accepts it.
|
||||
*/
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
keylen: number,
|
||||
options: ScryptOptions,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, keylen, options, (error, derivedKey) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Password hashing.
|
||||
*
|
||||
* ALGORITHM CHOICE
|
||||
* scrypt from `node:crypto`, at OWASP's recommended parameters. Argon2id would
|
||||
* be the first choice on paper, but every Node binding for it is a native
|
||||
* module — a compile step in CI, a platform matrix in Docker, and a class of
|
||||
* deployment failure that has nothing to do with this application. scrypt is
|
||||
* memory-hard, standardised (RFC 7914), on OWASP's approved list, and already
|
||||
* in the runtime.
|
||||
*
|
||||
* ALGORITHM AGILITY
|
||||
* Hashes are stored self-describing:
|
||||
*
|
||||
* scrypt$16384$8$1$<salt-b64>$<hash-b64>
|
||||
*
|
||||
* The verifier reads its parameters from the stored string rather than from
|
||||
* today's constants, so raising the cost — or moving to Argon2id later — is a
|
||||
* transparent rehash-on-next-login. `needsRehash()` reports when that applies.
|
||||
* A password store you cannot upgrade is a password store you are stuck with.
|
||||
*/
|
||||
|
||||
/**
|
||||
* N=2^14 (16 MiB), r=8, p=1 — OWASP's minimum for scrypt.
|
||||
*
|
||||
* Deliberately not higher: this runs on the login path, and a cost that makes
|
||||
* sign-in feel slow is a cost someone will quietly lower in six months. The
|
||||
* `maxmem` bump is required because Node's default ceiling is below what N
|
||||
* needs.
|
||||
*/
|
||||
const CURRENT_PARAMS = { N: 16_384, r: 8, p: 1 } as const;
|
||||
const KEY_LENGTH = 64;
|
||||
const SALT_LENGTH = 16;
|
||||
const MAX_MEM = 64 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
private readonly logger = new Logger(PasswordService.name);
|
||||
|
||||
async hash(plaintext: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_LENGTH);
|
||||
const derived = await this.derive(plaintext, salt, CURRENT_PARAMS);
|
||||
|
||||
return [
|
||||
'scrypt',
|
||||
CURRENT_PARAMS.N,
|
||||
CURRENT_PARAMS.r,
|
||||
CURRENT_PARAMS.p,
|
||||
salt.toString('base64'),
|
||||
derived.toString('base64'),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time verification.
|
||||
*
|
||||
* Returns false on a malformed hash rather than throwing: a corrupt row must
|
||||
* read as "wrong password", not as a 500 that tells an attacker the account
|
||||
* exists and is in an unusual state.
|
||||
*/
|
||||
async verify(plaintext: string, stored: string | null | undefined): Promise<boolean> {
|
||||
if (!stored) return false;
|
||||
|
||||
const parsed = this.parse(stored);
|
||||
if (!parsed) {
|
||||
this.logger.warn('Encountered an unparseable password hash');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const derived = await this.derive(plaintext, parsed.salt, parsed.params);
|
||||
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
|
||||
} catch (error) {
|
||||
this.logger.error(`Password verification failed: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the stored hash uses weaker parameters than we now require. */
|
||||
needsRehash(stored: string): boolean {
|
||||
const parsed = this.parse(stored);
|
||||
if (!parsed) return true;
|
||||
|
||||
return (
|
||||
parsed.params.N < CURRENT_PARAMS.N ||
|
||||
parsed.params.r < CURRENT_PARAMS.r ||
|
||||
parsed.params.p < CURRENT_PARAMS.p
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hash of a throwaway value, used to keep login timing flat when the email
|
||||
* does not exist. Without it, "unknown email" returns measurably faster than
|
||||
* "wrong password", which turns the login form into a user enumeration oracle.
|
||||
*/
|
||||
async burnCycles(): Promise<void> {
|
||||
await this.derive('timing-equalisation', Buffer.alloc(SALT_LENGTH), CURRENT_PARAMS);
|
||||
}
|
||||
|
||||
private derive(
|
||||
plaintext: string,
|
||||
salt: Buffer,
|
||||
params: { N: number; r: number; p: number },
|
||||
): Promise<Buffer> {
|
||||
return scryptAsync(plaintext.normalize('NFKC'), salt, KEY_LENGTH, {
|
||||
...params,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
}
|
||||
|
||||
private parse(
|
||||
stored: string,
|
||||
): { params: { N: number; r: number; p: number }; salt: Buffer; hash: Buffer } | null {
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
|
||||
|
||||
const [, rawN, rawR, rawP, rawSalt, rawHash] = parts;
|
||||
const N = Number(rawN);
|
||||
const r = Number(rawR);
|
||||
const p = Number(rawP);
|
||||
|
||||
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return null;
|
||||
if (!rawSalt || !rawHash) return null;
|
||||
|
||||
return {
|
||||
params: { N, r, p },
|
||||
salt: Buffer.from(rawSalt, 'base64'),
|
||||
hash: Buffer.from(rawHash, 'base64'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
/**
|
||||
* Password handling has no second chance: a bug here is either "nobody can log
|
||||
* in" or "everybody can". These tests pin the properties that matter rather
|
||||
* than the implementation.
|
||||
*/
|
||||
describe('PasswordService', () => {
|
||||
const service = new PasswordService();
|
||||
|
||||
it('round-trips a password', async () => {
|
||||
const hash = await service.hash('Correct horse battery 1');
|
||||
await expect(service.verify('Correct horse battery 1', hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the wrong password', async () => {
|
||||
const hash = await service.hash('Correct horse battery 1');
|
||||
await expect(service.verify('Correct horse battery 2', hash)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('salts, so the same password hashes differently every time', async () => {
|
||||
const [a, b] = await Promise.all([service.hash('SameInput123'), service.hash('SameInput123')]);
|
||||
|
||||
expect(a).not.toBe(b);
|
||||
await expect(service.verify('SameInput123', a)).resolves.toBe(true);
|
||||
await expect(service.verify('SameInput123', b)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('emits a self-describing hash so parameters can change later', async () => {
|
||||
const hash = await service.hash('Parameters123');
|
||||
const [algorithm, n, r, p] = hash.split('$');
|
||||
|
||||
expect(algorithm).toBe('scrypt');
|
||||
expect(Number(n)).toBeGreaterThanOrEqual(16_384);
|
||||
expect(Number(r)).toBeGreaterThanOrEqual(8);
|
||||
expect(Number(p)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('treats a null or malformed hash as a failed verification, never an error', async () => {
|
||||
// A corrupt row must read as "wrong password". Throwing would tell an
|
||||
// attacker the account exists and is in an unusual state.
|
||||
await expect(service.verify('anything', null)).resolves.toBe(false);
|
||||
await expect(service.verify('anything', undefined)).resolves.toBe(false);
|
||||
await expect(service.verify('anything', 'not-a-hash')).resolves.toBe(false);
|
||||
await expect(service.verify('anything', 'scrypt$bad$params$here$x$y')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('normalises unicode, so an accented password survives a different keyboard', async () => {
|
||||
// U+00E9 vs U+0065 U+0301 — visually identical, different bytes.
|
||||
const composed = 'caféPassw0rd';
|
||||
const decomposed = 'caféPassw0rd';
|
||||
|
||||
const hash = await service.hash(composed);
|
||||
await expect(service.verify(decomposed, hash)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('flags weaker stored parameters for rehash', () => {
|
||||
expect(service.needsRehash('scrypt$1024$8$1$c2FsdA==$aGFzaA==')).toBe(true);
|
||||
expect(service.needsRehash('garbage')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a current hash for rehash', async () => {
|
||||
const hash = await service.hash('CurrentParams1');
|
||||
expect(service.needsRehash(hash)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
/**
|
||||
* Stateless security primitives.
|
||||
*
|
||||
* `PasswordService` lives here rather than in AuthModule to break a dependency
|
||||
* cycle: AuthModule needs UsersModule to look up accounts, and UsersModule
|
||||
* needs password hashing to create them. Hashing has no dependencies of its
|
||||
* own, so hoisting it out of both is the fix — `forwardRef` would only hide the
|
||||
* cycle rather than remove it.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PasswordService],
|
||||
exports: [PasswordService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -45,7 +45,17 @@ export const envSchema = z.object({
|
||||
STORAGE_PUBLIC_URL: z.url(),
|
||||
|
||||
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
|
||||
/**
|
||||
* Coarse per-IP ceiling — a DoS safety net, not the real protection.
|
||||
*
|
||||
* Raised from 120: many users share one address behind corporate NAT or
|
||||
* carrier-grade NAT, and a catalog page makes several API calls. At 120/min a
|
||||
* single office could exhaust the budget and start receiving 429s on
|
||||
* `/auth/refresh`, which the client correctly reads as "session over" and
|
||||
* signs everyone out. Fine-grained protection lives per-endpoint — see
|
||||
* LoginThrottleService, which counts failures per account and per IP.
|
||||
*/
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
|
||||
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
LOG_PRETTY: z.stringbool().default(false),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import compression from 'compression';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
@@ -31,6 +32,9 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
// First in the chain: every log line and error response carries this id.
|
||||
app.use(requestIdMiddleware);
|
||||
// Refresh tokens arrive as httpOnly cookies; without this `req.cookies` is
|
||||
// undefined and every refresh silently fails as "no session".
|
||||
app.use(cookieParser());
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
app.use(compression());
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
TOKEN_AUDIENCES,
|
||||
type AuthenticatedActor,
|
||||
type CurrentUser,
|
||||
type LoginResult,
|
||||
type RefreshResult,
|
||||
type SessionSummary,
|
||||
type TokenAudience,
|
||||
} from '@sport/types';
|
||||
import { loginSchema, type LoginInput } from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import { Public } from '@/common/decorators/public.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
import { AuthService, type IssuedSession, type RequestContext } from './auth.service';
|
||||
import { clearRefreshCookie, readRefreshCookie, setRefreshCookie } from './refresh-cookie';
|
||||
|
||||
/**
|
||||
* Credential exchange.
|
||||
*
|
||||
* Storefront and admin have separate login endpoints rather than one endpoint
|
||||
* that infers the audience. The audience decides which population may sign in
|
||||
* and which cookie is issued — inferring it from the account would mean a
|
||||
* single leaked customer credential could be pointed at the admin surface and
|
||||
* only a later check would stop it. Two routes make the boundary explicit.
|
||||
*/
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Customer sign-in (storefront audience)' })
|
||||
login(
|
||||
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<LoginResult> {
|
||||
return this.handleLogin(body, TOKEN_AUDIENCES.STOREFRONT, request, response);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Back-office sign-in (admin audience)' })
|
||||
adminLogin(
|
||||
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<LoginResult> {
|
||||
return this.handleLogin(body, TOKEN_AUDIENCES.ADMIN, request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public because it authenticates with the cookie, not with an access token —
|
||||
* the whole point is to be callable once the access token has expired.
|
||||
*/
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Rotate the storefront refresh token' })
|
||||
refresh(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<RefreshResult> {
|
||||
return this.handleRefresh(TOKEN_AUDIENCES.STOREFRONT, request, response);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Rotate the admin refresh token' })
|
||||
adminRefresh(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<RefreshResult> {
|
||||
return this.handleRefresh(TOKEN_AUDIENCES.ADMIN, request, response);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Sign out of the storefront' })
|
||||
logout(@Req() request: Request, @Res({ passthrough: true }) response: Response): Promise<void> {
|
||||
return this.handleLogout(TOKEN_AUDIENCES.STOREFRONT, request, response);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Sign out of the admin' })
|
||||
adminLogout(
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<void> {
|
||||
return this.handleLogout(TOKEN_AUDIENCES.ADMIN, request, response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'The signed-in user, with roles and permissions' })
|
||||
me(@CurrentActor() actor: AuthenticatedActor): Promise<CurrentUser> {
|
||||
return this.authService.me(actor.userId);
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Active sessions for the signed-in user' })
|
||||
sessions(@CurrentActor() actor: AuthenticatedActor): Promise<SessionSummary[]> {
|
||||
return this.authService.listSessions(actor.userId, actor.sessionId);
|
||||
}
|
||||
|
||||
// ---- shared handlers -----------------------------------------------------
|
||||
|
||||
private async handleLogin(
|
||||
body: LoginInput,
|
||||
audience: TokenAudience,
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Promise<LoginResult> {
|
||||
const { result, session } = await this.authService.login(body, audience, contextOf(request));
|
||||
this.writeSession(response, audience, session);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async handleRefresh(
|
||||
audience: TokenAudience,
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Promise<RefreshResult> {
|
||||
const { result, session } = await this.authService.refresh(
|
||||
readRefreshCookie(request, audience),
|
||||
audience,
|
||||
contextOf(request),
|
||||
);
|
||||
|
||||
this.writeSession(response, audience, session);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async handleLogout(
|
||||
audience: TokenAudience,
|
||||
request: Request,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
await this.authService.logout(readRefreshCookie(request, audience));
|
||||
clearRefreshCookie(response, audience, this.config.app.isProduction);
|
||||
}
|
||||
|
||||
private writeSession(response: Response, audience: TokenAudience, session: IssuedSession): void {
|
||||
setRefreshCookie(
|
||||
response,
|
||||
audience,
|
||||
session.refreshToken,
|
||||
session.refreshTokenExpiresAt,
|
||||
this.config.app.isProduction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client IP comes from Express's `trust proxy` handling, which is why
|
||||
* `app.set('trust proxy', 1)` in main.ts matters — without it every request
|
||||
* behind Nginx would look like it came from the proxy, and the per-IP login
|
||||
* throttle would lock out the entire internet at once.
|
||||
*/
|
||||
function contextOf(request: Request): RequestContext {
|
||||
return {
|
||||
userAgent: request.header('user-agent')?.slice(0, 512) ?? null,
|
||||
ipAddress: request.ip ?? null,
|
||||
};
|
||||
}
|
||||
@@ -2,27 +2,38 @@ import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { UsersModule } from '@/modules/users/users.module';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AccessTokenGuard } from './guards/access-token.guard';
|
||||
import { PermissionsGuard } from './guards/permissions.guard';
|
||||
import { LoginThrottleService } from './login-throttle.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
/**
|
||||
* Milestone 0 provides the *enforcement* half of auth: token verification,
|
||||
* audience separation and RBAC evaluation, wired globally.
|
||||
* AuthModule — owns `sessions`, and nothing else.
|
||||
*
|
||||
* The *issuance* half — login, registration, refresh rotation, password reset,
|
||||
* OTP — is milestone 1. Splitting it this way means every endpoint written from
|
||||
* here on is protected by default, before a single credential exists.
|
||||
* Accounts, roles and permissions belong to UsersModule; this module exchanges
|
||||
* credentials for tokens and manages session lifetime. Password *hashing* lives
|
||||
* in `common/security` so that both modules can use it without a cycle.
|
||||
*
|
||||
* Guard order matters: AccessTokenGuard must populate `request.actor` before
|
||||
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
imports: [JwtModule.register({}), UsersModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthRepository,
|
||||
TokenService,
|
||||
LoginThrottleService,
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
exports: [JwtModule],
|
||||
exports: [JwtModule, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* The session table — the only data AuthModule owns.
|
||||
*
|
||||
* One row per refresh token. Rotation appends a new row and links the old one
|
||||
* to it, so a session's full history is reconstructable, which is what makes
|
||||
* token-reuse detection possible at all.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findByRefreshHash(refreshTokenHash: string) {
|
||||
return this.prisma.session.findUnique({
|
||||
where: { refreshTokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
familyId: true,
|
||||
replacedById: true,
|
||||
revokedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
create(params: {
|
||||
userId: string;
|
||||
familyId: string;
|
||||
refreshTokenHash: string;
|
||||
expiresAt: Date;
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
}) {
|
||||
return this.prisma.session.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
familyId: params.familyId,
|
||||
refreshTokenHash: params.refreshTokenHash,
|
||||
expiresAt: params.expiresAt,
|
||||
userAgent: params.userAgent,
|
||||
ipAddress: params.ipAddress,
|
||||
},
|
||||
select: { id: true, familyId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a session: creates the successor and links the predecessor to it,
|
||||
* in one transaction.
|
||||
*
|
||||
* If this were two statements and the second failed, the old token would stay
|
||||
* valid alongside the new one — two live credentials from one refresh, which
|
||||
* defeats the point of rotating.
|
||||
*/
|
||||
async rotate(params: {
|
||||
previousSessionId: string;
|
||||
userId: string;
|
||||
familyId: string;
|
||||
refreshTokenHash: string;
|
||||
expiresAt: Date;
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const next = await tx.session.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
familyId: params.familyId,
|
||||
refreshTokenHash: params.refreshTokenHash,
|
||||
expiresAt: params.expiresAt,
|
||||
userAgent: params.userAgent,
|
||||
ipAddress: params.ipAddress,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.session.update({
|
||||
where: { id: params.previousSessionId },
|
||||
data: { replacedById: next.id },
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Revokes one session — a single device signing out. */
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await this.prisma.session.updateMany({
|
||||
where: { id: sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an entire token family.
|
||||
*
|
||||
* Called when a refresh token is replayed. Either the token leaked or the
|
||||
* client is broken; both warrant forcing a fresh sign-in on that device.
|
||||
*/
|
||||
async revokeFamily(familyId: string): Promise<number> {
|
||||
const result = await this.prisma.session.updateMany({
|
||||
where: { familyId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/** Signs a user out everywhere — used after a password change. */
|
||||
async revokeAllForUser(userId: string): Promise<number> {
|
||||
const result = await this.prisma.session.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
listActiveForUser(userId: string) {
|
||||
return this.prisma.session.findMany({
|
||||
where: { userId, revokedAt: null, replacedById: null, expiresAt: { gt: new Date() } },
|
||||
select: {
|
||||
id: true,
|
||||
userAgent: true,
|
||||
ipAddress: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Housekeeping for expired rows.
|
||||
*
|
||||
* Rotation is append-only, so this table grows with every refresh — a daily
|
||||
* job calls this. Rows are kept a week past expiry so a security review can
|
||||
* still see what happened.
|
||||
*/
|
||||
async deleteExpired(before = new Date(Date.now() - 7 * 86_400_000)): Promise<number> {
|
||||
const result = await this.prisma.session.deleteMany({ where: { expiresAt: { lt: before } } });
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
TOKEN_AUDIENCES,
|
||||
isBackOfficeUser,
|
||||
type CurrentUser,
|
||||
type LoginResult,
|
||||
type RefreshResult,
|
||||
type SessionSummary,
|
||||
type TokenAudience,
|
||||
type UserType,
|
||||
} from '@sport/types';
|
||||
import type { LoginInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { PasswordService } from '@/common/security/password.service';
|
||||
import { UsersService, type AuthUserRow } from '@/modules/users/public';
|
||||
|
||||
import { AuthRepository } from './auth.repository';
|
||||
import { LoginThrottleService } from './login-throttle.service';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
export interface RequestContext {
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
export interface IssuedSession {
|
||||
accessToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
refreshToken: string;
|
||||
refreshTokenExpiresAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly passwordService: PasswordService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly repository: AuthRepository,
|
||||
private readonly throttle: LoginThrottleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Exchanges credentials for a session.
|
||||
*
|
||||
* Every failure path returns the same message and the same status. Telling a
|
||||
* caller apart — "no such account" vs "wrong password" vs "suspended" — turns
|
||||
* the login form into a user-enumeration oracle, and the timing is equalised
|
||||
* for the same reason.
|
||||
*/
|
||||
async login(
|
||||
input: LoginInput,
|
||||
audience: TokenAudience,
|
||||
context: RequestContext,
|
||||
): Promise<{ result: LoginResult; session: IssuedSession }> {
|
||||
await this.throttle.assertNotLocked(input.email, context.ipAddress);
|
||||
|
||||
const user = await this.usersService.findForAuthByEmail(input.email);
|
||||
|
||||
if (!user) {
|
||||
// Spend the same CPU as a real verification so a missing account is not
|
||||
// measurably faster than a wrong password.
|
||||
await this.passwordService.burnCycles();
|
||||
await this.throttle.recordFailure(input.email, context.ipAddress);
|
||||
throw invalidCredentials();
|
||||
}
|
||||
|
||||
const passwordValid = await this.passwordService.verify(input.password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
await this.throttle.recordFailure(input.email, context.ipAddress);
|
||||
throw invalidCredentials();
|
||||
}
|
||||
|
||||
if (user.status !== 'ACTIVE') {
|
||||
await this.throttle.recordFailure(input.email, context.ipAddress);
|
||||
throw invalidCredentials();
|
||||
}
|
||||
|
||||
this.assertAudience(user.type as UserType, audience);
|
||||
|
||||
// Transparent upgrade if the stored hash predates the current cost.
|
||||
if (user.passwordHash && this.passwordService.needsRehash(user.passwordHash)) {
|
||||
await this.usersService.updatePasswordHash(
|
||||
user.id,
|
||||
await this.passwordService.hash(input.password),
|
||||
);
|
||||
this.logger.log(`Upgraded password hash parameters for user ${user.id}`);
|
||||
}
|
||||
|
||||
const session = await this.startSession(user, audience, context);
|
||||
|
||||
await Promise.all([
|
||||
this.usersService.recordLogin(user.id),
|
||||
this.throttle.recordSuccess(input.email),
|
||||
]);
|
||||
|
||||
return {
|
||||
result: {
|
||||
user: this.usersService.toCurrentUser(user),
|
||||
accessToken: session.accessToken,
|
||||
accessTokenExpiresAt: session.accessTokenExpiresAt.toISOString(),
|
||||
},
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a refresh token.
|
||||
*
|
||||
* The security-critical branch is reuse detection: a token that has already
|
||||
* been rotated or revoked must never work again, and being presented with one
|
||||
* means either it leaked or the client is broken. Both justify killing the
|
||||
* whole family — that is the difference between detecting theft and merely
|
||||
* limiting its window.
|
||||
*/
|
||||
async refresh(
|
||||
refreshToken: string | undefined,
|
||||
audience: TokenAudience,
|
||||
context: RequestContext,
|
||||
): Promise<{ result: RefreshResult; session: IssuedSession }> {
|
||||
if (!refreshToken) {
|
||||
throw AppException.unauthenticated('Your session has expired. Please sign in again.');
|
||||
}
|
||||
|
||||
const hash = this.tokenService.hashRefreshToken(refreshToken);
|
||||
const existing = await this.repository.findByRefreshHash(hash);
|
||||
|
||||
if (!existing) {
|
||||
throw AppException.unauthenticated(
|
||||
'Your session has expired. Please sign in again.',
|
||||
API_ERROR_CODES.TOKEN_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.revokedAt !== null || existing.replacedById !== null) {
|
||||
const revoked = await this.repository.revokeFamily(existing.familyId);
|
||||
this.logger.error(
|
||||
`Refresh token reuse detected for user ${existing.userId}; revoked ${revoked} session(s) in family ${existing.familyId}`,
|
||||
);
|
||||
|
||||
throw AppException.unauthenticated(
|
||||
'Your session is no longer valid. Please sign in again.',
|
||||
API_ERROR_CODES.TOKEN_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.expiresAt.getTime() <= Date.now()) {
|
||||
throw AppException.unauthenticated(
|
||||
'Your session has expired. Please sign in again.',
|
||||
API_ERROR_CODES.TOKEN_EXPIRED,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.usersService.findForAuthById(existing.userId);
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
await this.repository.revokeFamily(existing.familyId);
|
||||
throw AppException.unauthenticated('Your session is no longer valid. Please sign in again.');
|
||||
}
|
||||
|
||||
this.assertAudience(user.type as UserType, audience);
|
||||
|
||||
const refresh = this.tokenService.issueRefreshToken();
|
||||
const next = await this.repository.rotate({
|
||||
previousSessionId: existing.id,
|
||||
userId: user.id,
|
||||
familyId: existing.familyId,
|
||||
refreshTokenHash: refresh.hash,
|
||||
expiresAt: refresh.expiresAt,
|
||||
userAgent: context.userAgent,
|
||||
ipAddress: context.ipAddress,
|
||||
});
|
||||
|
||||
// Permissions are re-read from the database on every rotation, so a role
|
||||
// change takes effect within one access-token lifetime rather than
|
||||
// persisting for the life of the refresh token.
|
||||
const access = await this.tokenService.issueAccessToken({
|
||||
userId: user.id,
|
||||
userType: user.type as UserType,
|
||||
audience,
|
||||
permissions: this.usersService.permissionsOf(user),
|
||||
sessionId: next.id,
|
||||
});
|
||||
|
||||
return {
|
||||
result: {
|
||||
accessToken: access.token,
|
||||
accessTokenExpiresAt: access.expiresAt.toISOString(),
|
||||
},
|
||||
session: {
|
||||
accessToken: access.token,
|
||||
accessTokenExpiresAt: access.expiresAt,
|
||||
refreshToken: refresh.token,
|
||||
refreshTokenExpiresAt: refresh.expiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Signs out one device. Idempotent — an unknown token is still a success. */
|
||||
async logout(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
|
||||
const existing = await this.repository.findByRefreshHash(
|
||||
this.tokenService.hashRefreshToken(refreshToken),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await this.repository.revokeFamily(existing.familyId);
|
||||
}
|
||||
}
|
||||
|
||||
async me(userId: string): Promise<CurrentUser> {
|
||||
const user = await this.usersService.findForAuthById(userId);
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
throw AppException.unauthenticated();
|
||||
}
|
||||
|
||||
return this.usersService.toCurrentUser(user);
|
||||
}
|
||||
|
||||
async listSessions(userId: string, currentSessionId: string): Promise<SessionSummary[]> {
|
||||
const rows = await this.repository.listActiveForUser(userId);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
userAgent: row.userAgent,
|
||||
ipAddress: row.ipAddress,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
isCurrent: row.id === currentSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- internals -----------------------------------------------------------
|
||||
|
||||
private async startSession(
|
||||
user: AuthUserRow,
|
||||
audience: TokenAudience,
|
||||
context: RequestContext,
|
||||
): Promise<IssuedSession> {
|
||||
const refresh = this.tokenService.issueRefreshToken();
|
||||
|
||||
const session = await this.repository.create({
|
||||
userId: user.id,
|
||||
// A fresh sign-in starts a new family; rotation stays within it. That is
|
||||
// what keeps revoking one compromised device from signing out the rest.
|
||||
familyId: this.tokenService.newSessionFamilyId(),
|
||||
refreshTokenHash: refresh.hash,
|
||||
expiresAt: refresh.expiresAt,
|
||||
userAgent: context.userAgent,
|
||||
ipAddress: context.ipAddress,
|
||||
});
|
||||
|
||||
const access = await this.tokenService.issueAccessToken({
|
||||
userId: user.id,
|
||||
userType: user.type as UserType,
|
||||
audience,
|
||||
permissions: this.usersService.permissionsOf(user),
|
||||
sessionId: session.id,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: access.token,
|
||||
accessTokenExpiresAt: access.expiresAt,
|
||||
refreshToken: refresh.token,
|
||||
refreshTokenExpiresAt: refresh.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer may never obtain an admin token, and a staff account may not
|
||||
* sign in through the storefront form.
|
||||
*
|
||||
* Checked at issuance as well as at every request (AccessTokenGuard), because
|
||||
* a token that should never have existed is worse than one that is merely
|
||||
* rejected later.
|
||||
*/
|
||||
private assertAudience(userType: UserType, audience: TokenAudience): void {
|
||||
const allowed =
|
||||
audience === TOKEN_AUDIENCES.ADMIN ? isBackOfficeUser(userType) : userType === 'CUSTOMER';
|
||||
|
||||
if (!allowed) {
|
||||
throw invalidCredentials();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One message, one status, for every failure mode. */
|
||||
function invalidCredentials(): AppException {
|
||||
return AppException.unauthenticated(
|
||||
'Email or password is incorrect.',
|
||||
API_ERROR_CODES.INVALID_CREDENTIALS,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES } from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys';
|
||||
import { RedisService } from '@/infrastructure/redis/redis.service';
|
||||
|
||||
/**
|
||||
* Login-specific rate limiting, on top of the global per-IP throttle.
|
||||
*
|
||||
* Two counters, because they stop different attacks:
|
||||
*
|
||||
* - **per account** — someone guessing one user's password. A botnet spreads
|
||||
* across many IPs, so an IP counter alone never trips.
|
||||
* - **per IP** — someone spraying one common password across many accounts.
|
||||
* An account counter alone never trips for that.
|
||||
*
|
||||
* Counting only failures means a busy legitimate user is never locked out, and
|
||||
* a successful login clears the account counter.
|
||||
*/
|
||||
const MAX_FAILURES_PER_ACCOUNT = 8;
|
||||
const MAX_FAILURES_PER_IP = 30;
|
||||
const WINDOW_SECONDS = 15 * 60;
|
||||
|
||||
@Injectable()
|
||||
export class LoginThrottleService {
|
||||
private readonly logger = new Logger(LoginThrottleService.name);
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async assertNotLocked(email: string, ipAddress: string | null): Promise<void> {
|
||||
const [accountFailures, ipFailures] = await Promise.all([
|
||||
this.peek(CACHE_KEYS.rateLimit('login:account', email.toLowerCase())),
|
||||
ipAddress ? this.peek(CACHE_KEYS.rateLimit('login:ip', ipAddress)) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
if (accountFailures >= MAX_FAILURES_PER_ACCOUNT || ipFailures >= MAX_FAILURES_PER_IP) {
|
||||
this.logger.warn(
|
||||
`Login blocked by throttle (account failures: ${accountFailures}, ip failures: ${ipFailures})`,
|
||||
);
|
||||
|
||||
throw new AppException({
|
||||
code: API_ERROR_CODES.RATE_LIMITED,
|
||||
message: 'Too many failed sign-in attempts. Please try again in a few minutes.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async recordFailure(email: string, ipAddress: string | null): Promise<void> {
|
||||
await Promise.all([
|
||||
this.redis.increment(
|
||||
CACHE_KEYS.rateLimit('login:account', email.toLowerCase()),
|
||||
WINDOW_SECONDS,
|
||||
),
|
||||
ipAddress
|
||||
? this.redis.increment(CACHE_KEYS.rateLimit('login:ip', ipAddress), WINDOW_SECONDS)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the account counter on success. The IP counter is deliberately left
|
||||
* alone: one correct password should not reset a spray in progress from the
|
||||
* same address.
|
||||
*/
|
||||
async recordSuccess(email: string): Promise<void> {
|
||||
await this.redis.delete(CACHE_KEYS.rateLimit('login:account', email.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a counter without incrementing. Redis being unavailable must not
|
||||
* block sign-in — the global throttle and the password itself still apply.
|
||||
*/
|
||||
private async peek(key: string): Promise<number> {
|
||||
try {
|
||||
const value = await this.redis.get<number>(key);
|
||||
return typeof value === 'number' ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Public surface of AuthModule.
|
||||
*
|
||||
* Deliberately narrow. `TokenService`, `AuthRepository` and the throttle stay
|
||||
* private — nothing outside this module should be minting tokens or writing to
|
||||
* the session table.
|
||||
*
|
||||
* Note that `PasswordService` is NOT here: it lives in `common/security`
|
||||
* because UsersModule needs it too, and routing it through this module would
|
||||
* create a dependency cycle.
|
||||
*/
|
||||
export { AuthService } from '../auth.service';
|
||||
export type { RequestContext } from '../auth.service';
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
|
||||
import type { TokenAudience } from '@sport/types';
|
||||
|
||||
/**
|
||||
* Refresh-token cookie handling.
|
||||
*
|
||||
* Storefront and admin get *separately named* cookies. Sharing one name would
|
||||
* mean signing into the admin silently replaces a customer session on the same
|
||||
* browser — and worse, that a single cookie could be replayed against the other
|
||||
* audience.
|
||||
*/
|
||||
const COOKIE_NAMES: Record<TokenAudience, string> = {
|
||||
storefront: 'sport_refresh',
|
||||
admin: 'sport_admin_refresh',
|
||||
};
|
||||
|
||||
/**
|
||||
* Scoped to the refresh endpoints only.
|
||||
*
|
||||
* The browser then sends this cookie on exactly two requests instead of
|
||||
* attaching it to every API call — so an XSS that can read responses still
|
||||
* never sees it, and it is not sitting in the headers of hundreds of unrelated
|
||||
* requests waiting to be logged somewhere.
|
||||
*/
|
||||
const COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
export function refreshCookieName(audience: TokenAudience): string {
|
||||
return COOKIE_NAMES[audience];
|
||||
}
|
||||
|
||||
export function readRefreshCookie(request: Request, audience: TokenAudience): string | undefined {
|
||||
const cookies = request.cookies as Record<string, string> | undefined;
|
||||
return cookies?.[refreshCookieName(audience)];
|
||||
}
|
||||
|
||||
export function setRefreshCookie(
|
||||
response: Response,
|
||||
audience: TokenAudience,
|
||||
token: string,
|
||||
expiresAt: Date,
|
||||
isProduction: boolean,
|
||||
): void {
|
||||
response.cookie(refreshCookieName(audience), token, {
|
||||
...baseOptions(isProduction),
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRefreshCookie(
|
||||
response: Response,
|
||||
audience: TokenAudience,
|
||||
isProduction: boolean,
|
||||
): void {
|
||||
response.clearCookie(refreshCookieName(audience), baseOptions(isProduction));
|
||||
}
|
||||
|
||||
function baseOptions(isProduction: boolean): CookieOptions {
|
||||
return {
|
||||
// JavaScript cannot read it. This is the property that makes an XSS unable
|
||||
// to steal the credential that mints new sessions.
|
||||
httpOnly: true,
|
||||
|
||||
// HTTPS only in production. Local development runs on plain HTTP, and a
|
||||
// Secure cookie would simply never be set — which looks like a broken
|
||||
// login rather than a config choice.
|
||||
secure: isProduction,
|
||||
|
||||
/**
|
||||
* `lax` works because the frontends reach the API through their *own*
|
||||
* origin — Nginx in production, a Next.js rewrite in development — so this
|
||||
* is a first-party cookie. Cross-origin would force `SameSite=None`, which
|
||||
* requires `Secure` and therefore cannot work over local HTTP at all.
|
||||
*/
|
||||
sameSite: 'lax',
|
||||
path: COOKIE_PATH,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import type { AccessTokenClaims, Permission, TokenAudience, UserType } from '@sport/types';
|
||||
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
export interface IssuedAccessToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface IssuedRefreshToken {
|
||||
/** The value handed to the client. Never stored. */
|
||||
token: string;
|
||||
/** SHA-256 of the token — this is what the database keeps. */
|
||||
hash: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints and verifies tokens. Holds no state; sessions live in AuthRepository.
|
||||
*
|
||||
* The two token types are deliberately different in kind:
|
||||
*
|
||||
* - The **access token** is a JWT. Stateless, short-lived, carries the
|
||||
* permission set so guards do no database work on the hot path.
|
||||
* - The **refresh token** is opaque random bytes, not a JWT. There is nothing
|
||||
* for a client to read in it, and because it is checked against a database
|
||||
* row it can be revoked — which a stateless JWT fundamentally cannot be.
|
||||
*
|
||||
* Only a SHA-256 of the refresh token is stored. A database leak therefore
|
||||
* yields no usable credentials. SHA-256 rather than a password hash is correct
|
||||
* here: the token is 256 bits of entropy, so there is no dictionary to attack
|
||||
* and no reason to pay scrypt's cost on every refresh.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async issueAccessToken(params: {
|
||||
userId: string;
|
||||
userType: UserType;
|
||||
audience: TokenAudience;
|
||||
permissions: readonly Permission[];
|
||||
sessionId: string;
|
||||
}): Promise<IssuedAccessToken> {
|
||||
const expiresInSeconds = parseDuration(this.config.auth.accessTtl);
|
||||
|
||||
const token = await this.jwtService.signAsync(
|
||||
{
|
||||
sub: params.userId,
|
||||
aud: params.audience,
|
||||
type: params.userType,
|
||||
permissions: params.permissions,
|
||||
sid: params.sessionId,
|
||||
} satisfies Omit<AccessTokenClaims, 'iat' | 'exp'>,
|
||||
{
|
||||
secret: this.config.auth.accessSecret,
|
||||
issuer: this.config.auth.issuer,
|
||||
expiresIn: expiresInSeconds,
|
||||
},
|
||||
);
|
||||
|
||||
return { token, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) };
|
||||
}
|
||||
|
||||
issueRefreshToken(): IssuedRefreshToken {
|
||||
// 32 bytes = 256 bits. base64url so it is cookie- and URL-safe without
|
||||
// escaping.
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
|
||||
return {
|
||||
token,
|
||||
hash: this.hashRefreshToken(token),
|
||||
expiresAt: new Date(Date.now() + parseDuration(this.config.auth.refreshTtl) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
hashRefreshToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
newSessionFamilyId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
get refreshTtlSeconds(): number {
|
||||
return parseDuration(this.config.auth.refreshTtl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `15m` → 900. The env schema already guarantees the format, so an unparseable
|
||||
* value here means the validator and this function have drifted — which should
|
||||
* fail loudly at boot rather than silently issue an eternal token.
|
||||
*/
|
||||
export function parseDuration(value: string): number {
|
||||
const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration: ${value}`);
|
||||
}
|
||||
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 'ms':
|
||||
return Math.ceil(amount / 1000);
|
||||
case 's':
|
||||
return amount;
|
||||
case 'm':
|
||||
return amount * 60;
|
||||
case 'h':
|
||||
return amount * 3600;
|
||||
case 'd':
|
||||
return amount * 86_400;
|
||||
default:
|
||||
throw new Error(`Invalid duration unit: ${String(unit)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Public surface of UsersModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
* AuthModule consumes `UsersService` for account lookup, permission resolution
|
||||
* and login bookkeeping. The repositories, the mapper and every Prisma row type
|
||||
* stay private.
|
||||
*/
|
||||
export {};
|
||||
export { UsersService } from '../users.service';
|
||||
export type { AuthUserRow } from '../users.repository';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { PERMISSIONS, TOKEN_AUDIENCES, type PermissionGroup, type RoleDetail } from '@sport/types';
|
||||
import {
|
||||
createRoleSchema,
|
||||
updateRoleSchema,
|
||||
type CreateRoleInput,
|
||||
type UpdateRoleInput,
|
||||
} from '@sport/validation';
|
||||
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@ApiTags('admin/roles')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly rolesService: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.ROLE_READ)
|
||||
@ApiOperation({ summary: 'List roles with their permissions and user counts' })
|
||||
list(): Promise<RoleDetail[]> {
|
||||
return this.rolesService.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared before `:id` so the literal path wins — otherwise
|
||||
* `/admin/roles/permissions` resolves as a role with the id "permissions".
|
||||
*/
|
||||
@Get('permissions')
|
||||
@RequirePermissions(PERMISSIONS.ROLE_READ)
|
||||
@ApiOperation({ summary: 'The permission catalog, grouped by resource' })
|
||||
listPermissions(): Promise<PermissionGroup[]> {
|
||||
return this.rolesService.listPermissions();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.ROLE_READ)
|
||||
@ApiOperation({ summary: 'Get one role' })
|
||||
getById(@Param('id') id: string): Promise<RoleDetail> {
|
||||
return this.rolesService.getById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
|
||||
@ApiOperation({ summary: 'Create a role' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(createRoleSchema)) body: CreateRoleInput,
|
||||
): Promise<RoleDetail> {
|
||||
return this.rolesService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
|
||||
@ApiOperation({ summary: "Update a role's name, description or permissions" })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateRoleSchema)) body: UpdateRoleInput,
|
||||
): Promise<RoleDetail> {
|
||||
return this.rolesService.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions(PERMISSIONS.ROLE_MANAGE)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete a non-system role that nobody holds' })
|
||||
delete(@Param('id') id: string): Promise<void> {
|
||||
return this.rolesService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
const roleSelect = {
|
||||
id: true,
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
isSystem: true,
|
||||
permissions: { select: { permission: { select: { key: true } } } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class RolesRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.role.findMany({
|
||||
select: { ...roleSelect, _count: { select: { users: true } } },
|
||||
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.role.findUnique({
|
||||
where: { id },
|
||||
select: { ...roleSelect, _count: { select: { users: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
findByKey(key: string) {
|
||||
return this.prisma.role.findUnique({ where: { key }, select: roleSelect });
|
||||
}
|
||||
|
||||
findManyByIds(ids: readonly string[]) {
|
||||
return this.prisma.role.findMany({ where: { id: { in: [...ids] } }, select: { id: true } });
|
||||
}
|
||||
|
||||
listPermissions() {
|
||||
return this.prisma.permission.findMany({
|
||||
select: { key: true, resource: true, action: true },
|
||||
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a role and its grants in one transaction.
|
||||
*
|
||||
* Permission keys are resolved to ids here rather than trusted from the
|
||||
* client: an unknown key is dropped instead of silently creating a permission
|
||||
* that no guard will ever check.
|
||||
*/
|
||||
async create(input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissionKeys: readonly string[];
|
||||
}) {
|
||||
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
|
||||
|
||||
return this.prisma.role.create({
|
||||
data: {
|
||||
key: input.key,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isSystem: false,
|
||||
permissions: {
|
||||
createMany: { data: permissionIds.map((permissionId) => ({ permissionId })) },
|
||||
},
|
||||
},
|
||||
select: { ...roleSelect, _count: { select: { users: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: { name?: string; description?: string | null; permissionKeys?: readonly string[] },
|
||||
) {
|
||||
if (input.permissionKeys) {
|
||||
const permissionIds = await this.resolvePermissionIds(input.permissionKeys);
|
||||
|
||||
// Replace the grant set wholesale inside a transaction — a half-applied
|
||||
// permission change is a security incident, not a glitch.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.rolePermission.deleteMany({ where: { roleId: id } }),
|
||||
this.prisma.rolePermission.createMany({
|
||||
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return this.prisma.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.description === undefined ? {} : { description: input.description }),
|
||||
},
|
||||
select: { ...roleSelect, _count: { select: { users: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
return this.prisma.role.delete({ where: { id }, select: { id: true } });
|
||||
}
|
||||
|
||||
private async resolvePermissionIds(keys: readonly string[]): Promise<string[]> {
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const rows = await this.prisma.permission.findMany({
|
||||
where: { key: { in: [...keys] } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
}
|
||||
|
||||
export type RoleRow = NonNullable<Awaited<ReturnType<RolesRepository['findById']>>>;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ALL_PERMISSIONS, type PermissionGroup, type RoleDetail } from '@sport/types';
|
||||
import type { CreateRoleInput, UpdateRoleInput } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
|
||||
import { RolesRepository } from './roles.repository';
|
||||
import { UsersMapper } from './users.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(
|
||||
private readonly repository: RolesRepository,
|
||||
private readonly mapper: UsersMapper,
|
||||
) {}
|
||||
|
||||
async list(): Promise<RoleDetail[]> {
|
||||
const rows = await this.repository.findAll();
|
||||
return rows.map((row) => this.mapper.toRoleDetail(row));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<RoleDetail> {
|
||||
const row = await this.repository.findById(id);
|
||||
if (!row) throw AppException.notFound('Role');
|
||||
|
||||
return this.mapper.toRoleDetail(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission catalog, served from the database.
|
||||
*
|
||||
* The seed reconciles this table against the code catalog in @sport/types, so
|
||||
* what the role editor shows is exactly what the running guards enforce — a
|
||||
* deploy skew surfaces as a missing checkbox rather than a grant that silently
|
||||
* does nothing.
|
||||
*/
|
||||
async listPermissions(): Promise<PermissionGroup[]> {
|
||||
const rows = await this.repository.listPermissions();
|
||||
return this.mapper.toPermissionGroups(rows);
|
||||
}
|
||||
|
||||
async create(input: CreateRoleInput): Promise<RoleDetail> {
|
||||
const existing = await this.repository.findByKey(input.key);
|
||||
if (existing) {
|
||||
throw AppException.conflict('A role with that key already exists.');
|
||||
}
|
||||
|
||||
const row = await this.repository.create({
|
||||
key: input.key,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
permissionKeys: this.assertKnownPermissions(input.permissions),
|
||||
});
|
||||
|
||||
return this.mapper.toRoleDetail(row);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateRoleInput): Promise<RoleDetail> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) throw AppException.notFound('Role');
|
||||
|
||||
/**
|
||||
* System roles may have their permissions edited but not their identity.
|
||||
*
|
||||
* The seed reconciles system roles from code on every run, so a renamed key
|
||||
* would be silently recreated — and an operator would be left wondering why
|
||||
* their change vanished. Rejecting it is clearer than losing it.
|
||||
*/
|
||||
if (existing.isSystem && input.name !== undefined && input.name !== existing.name) {
|
||||
throw AppException.badRequest('System roles cannot be renamed.');
|
||||
}
|
||||
|
||||
const row = await this.repository.update(id, {
|
||||
...(input.name === undefined ? {} : { name: input.name }),
|
||||
...(input.description === undefined ? {} : { description: input.description ?? null }),
|
||||
...(input.permissions === undefined
|
||||
? {}
|
||||
: { permissionKeys: this.assertKnownPermissions(input.permissions) }),
|
||||
});
|
||||
|
||||
return this.mapper.toRoleDetail(row);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) throw AppException.notFound('Role');
|
||||
|
||||
if (existing.isSystem) {
|
||||
throw AppException.badRequest('System roles cannot be deleted.');
|
||||
}
|
||||
|
||||
// Deleting a role that people hold would silently strip their access.
|
||||
// Making the operator reassign first keeps the consequence visible.
|
||||
if (existing._count.users > 0) {
|
||||
throw AppException.conflict(
|
||||
`This role is assigned to ${existing._count.users} user(s). Reassign them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects permission keys the code does not define.
|
||||
*
|
||||
* Without this a typo'd key would be stored, displayed as granted, and never
|
||||
* match a guard — an access-control bug that looks like working configuration.
|
||||
*/
|
||||
private assertKnownPermissions(keys: readonly string[]): string[] {
|
||||
const known = new Set<string>(ALL_PERMISSIONS);
|
||||
const unknown = keys.filter((key) => !known.has(key));
|
||||
|
||||
if (unknown.length > 0) {
|
||||
throw AppException.badRequest(`Unknown permission(s): ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { PERMISSIONS, TOKEN_AUDIENCES, type OffsetPaginated, type UserSummary } from '@sport/types';
|
||||
import {
|
||||
createUserSchema,
|
||||
resetUserPasswordSchema,
|
||||
updateUserSchema,
|
||||
userListQuerySchema,
|
||||
type CreateUserInput,
|
||||
type UpdateUserInput,
|
||||
type UserListQuery,
|
||||
} from '@sport/validation';
|
||||
|
||||
import { CurrentActor } from '@/common/decorators/current-actor.decorator';
|
||||
import {
|
||||
RequireAudience,
|
||||
RequirePermissions,
|
||||
} from '@/common/decorators/require-permissions.decorator';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
|
||||
import { PasswordService } from '@/common/security/password.service';
|
||||
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/**
|
||||
* Back-office user administration.
|
||||
*
|
||||
* `@RequireAudience('admin')` on the controller means a storefront token is
|
||||
* rejected before any permission is even read — defence in depth, not an
|
||||
* optimisation.
|
||||
*/
|
||||
@ApiTags('admin/users')
|
||||
@ApiBearerAuth()
|
||||
@RequireAudience(TOKEN_AUDIENCES.ADMIN)
|
||||
@Controller('admin/users')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly passwordService: PasswordService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(PERMISSIONS.USER_READ)
|
||||
@ApiOperation({ summary: 'List back-office users' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
|
||||
): Promise<OffsetPaginated<UserSummary>> {
|
||||
return this.usersService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(PERMISSIONS.USER_READ)
|
||||
@ApiOperation({ summary: 'Get one user' })
|
||||
getById(@Param('id') id: string): Promise<UserSummary> {
|
||||
return this.usersService.getById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(PERMISSIONS.USER_MANAGE)
|
||||
@ApiOperation({ summary: 'Create a back-office user' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(createUserSchema)) body: CreateUserInput,
|
||||
): Promise<UserSummary> {
|
||||
const passwordHash = await this.passwordService.hash(body.password);
|
||||
return this.usersService.create(body, passwordHash);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions(PERMISSIONS.USER_MANAGE)
|
||||
@ApiOperation({ summary: 'Update a user, including their roles' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(updateUserSchema)) body: UpdateUserInput,
|
||||
@CurrentActor() actor: { userId: string },
|
||||
): Promise<UserSummary> {
|
||||
/**
|
||||
* An operator cannot change their own type, status or roles.
|
||||
*
|
||||
* This is the lockout guard: without it, an admin can demote themselves out
|
||||
* of the very permission needed to undo it, and the only recovery is a
|
||||
* database console.
|
||||
*/
|
||||
if (id === actor.userId) {
|
||||
const touchesOwnAccess =
|
||||
body.roleIds !== undefined || body.status !== undefined || body.type !== undefined;
|
||||
|
||||
if (touchesOwnAccess) {
|
||||
throw AppException.forbidden('You cannot change your own roles, type or status.');
|
||||
}
|
||||
}
|
||||
|
||||
return this.usersService.update(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/password')
|
||||
@RequirePermissions(PERMISSIONS.USER_MANAGE)
|
||||
@ApiOperation({ summary: "Reset another user's password" })
|
||||
async resetPassword(
|
||||
@Param('id') id: string,
|
||||
@Body(new ZodValidationPipe(resetUserPasswordSchema)) body: { password: string },
|
||||
): Promise<{ ok: true }> {
|
||||
await this.usersService.getById(id);
|
||||
await this.usersService.updatePasswordHash(id, await this.passwordService.hash(body.password));
|
||||
|
||||
// NOTE: existing sessions are intentionally NOT revoked here yet. Doing it
|
||||
// properly means revoking every session family for the user, which belongs
|
||||
// with the session-management screen rather than bolted on here.
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
CurrentUser,
|
||||
Permission,
|
||||
PermissionGroup,
|
||||
RoleDetail,
|
||||
UserStatus,
|
||||
UserSummary,
|
||||
UserType,
|
||||
} from '@sport/types';
|
||||
|
||||
import { MediaUrlService } from '@/common/media/media-url.service';
|
||||
|
||||
import type { RoleRow } from './roles.repository';
|
||||
import type { AuthUserRow, UserRow } from './users.repository';
|
||||
|
||||
@Injectable()
|
||||
export class UsersMapper {
|
||||
constructor(private readonly mediaUrl: MediaUrlService) {}
|
||||
|
||||
/**
|
||||
* Flattens role grants into a distinct permission set.
|
||||
*
|
||||
* Roles are additive and may overlap — a user with both "Catalog Manager" and
|
||||
* "Order Manager" gets the union, deduplicated. Nothing subtracts, which is
|
||||
* what keeps "why can this person do X?" answerable by listing their roles.
|
||||
*/
|
||||
permissionsOf(row: AuthUserRow): Permission[] {
|
||||
const permissions = new Set<string>();
|
||||
|
||||
for (const link of row.roles) {
|
||||
for (const grant of link.role.permissions) {
|
||||
permissions.add(grant.permission.key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...permissions] as Permission[];
|
||||
}
|
||||
|
||||
roleKeysOf(row: AuthUserRow): string[] {
|
||||
return row.roles.map((link) => link.role.key);
|
||||
}
|
||||
|
||||
toCurrentUser(row: AuthUserRow): CurrentUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
type: row.type as UserType,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
displayName: displayName(row.firstName, row.lastName, row.email),
|
||||
avatarUrl: row.avatar ? this.mediaUrl.url(row.avatar.storageKey) : null,
|
||||
roles: this.roleKeysOf(row),
|
||||
permissions: this.permissionsOf(row),
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
toSummary(row: UserRow): UserSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
type: row.type as UserType,
|
||||
status: row.status as UserStatus,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
displayName: displayName(row.firstName, row.lastName, row.email),
|
||||
roles: row.roles.map((link) => ({
|
||||
id: link.role.id,
|
||||
key: link.role.key,
|
||||
name: link.role.name,
|
||||
isSystem: link.role.isSystem,
|
||||
})),
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
toRoleDetail(row: RoleRow): RoleDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
isSystem: row.isSystem,
|
||||
permissions: row.permissions.map((grant) => grant.permission.key as Permission),
|
||||
userCount: row._count.users,
|
||||
};
|
||||
}
|
||||
|
||||
/** Groups the catalog by resource so the role editor renders as sections. */
|
||||
toPermissionGroups(
|
||||
rows: readonly { key: string; resource: string; action: string }[],
|
||||
): PermissionGroup[] {
|
||||
const groups = new Map<string, PermissionGroup['permissions'][number][]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const bucket = groups.get(row.resource) ?? [];
|
||||
bucket.push({ key: row.key as Permission, resource: row.resource, action: row.action });
|
||||
groups.set(row.resource, bucket);
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([resource, permissions]) => ({ resource, permissions }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Falls back to the email so a row never renders as an empty name. */
|
||||
function displayName(firstName: string | null, lastName: string | null, email: string): string {
|
||||
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
|
||||
return full.length > 0 ? full : email;
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesRepository } from './roles.repository';
|
||||
import { RolesService } from './roles.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersMapper } from './users.mapper';
|
||||
import { UsersRepository } from './users.repository';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
/**
|
||||
* UsersModule — boundary declared, implementation pending.
|
||||
* UsersModule — owns `users`, `roles`, `permissions`, `role_permissions` and
|
||||
* `user_roles`.
|
||||
*
|
||||
* Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles`
|
||||
*
|
||||
* Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* users.module.ts wiring only
|
||||
* users.controller.ts HTTP surface, no logic
|
||||
* users.service.ts business rules
|
||||
* users.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
* Back-office identity plus the RBAC administration surface. AuthModule reads
|
||||
* accounts through this module's public service; it never queries `users`
|
||||
* itself, which keeps credential exchange and account management separable.
|
||||
*/
|
||||
@Module({})
|
||||
@Module({
|
||||
controllers: [UsersController, RolesController],
|
||||
providers: [UsersService, RolesService, UsersRepository, RolesRepository, UsersMapper],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { UserListQuery } from '@sport/validation';
|
||||
|
||||
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
|
||||
|
||||
/** Everything needed to authenticate, in one query. */
|
||||
const authSelect = {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
type: true,
|
||||
status: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
lastLoginAt: true,
|
||||
avatar: { select: { storageKey: true } },
|
||||
roles: {
|
||||
select: {
|
||||
role: {
|
||||
select: {
|
||||
key: true,
|
||||
permissions: { select: { permission: { select: { key: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const summarySelect = {
|
||||
id: true,
|
||||
email: true,
|
||||
type: true,
|
||||
status: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
roles: {
|
||||
select: { role: { select: { id: true, key: true, name: true, isSystem: true } } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Soft-deleted accounts are invisible everywhere. Filtering here rather than
|
||||
* at each call site means a forgotten `deletedAt: null` cannot resurrect a
|
||||
* removed operator.
|
||||
*/
|
||||
private alive(): Prisma.UserWhereInput {
|
||||
return { deletedAt: null };
|
||||
}
|
||||
|
||||
findForAuthByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
// Emails are normalised to lowercase on write (@sport/validation), so a
|
||||
// plain equality match is correct and uses the unique index.
|
||||
where: { ...this.alive(), email: email.toLowerCase() },
|
||||
select: authSelect,
|
||||
});
|
||||
}
|
||||
|
||||
findForAuthById(id: string) {
|
||||
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: authSelect });
|
||||
}
|
||||
|
||||
async list(query: UserListQuery) {
|
||||
const where: Prisma.UserWhereInput = {
|
||||
...this.alive(),
|
||||
...(query.type ? { type: query.type } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.q
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: query.q, mode: 'insensitive' } },
|
||||
{ firstName: { contains: query.q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: query.q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, totalItems] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
select: summarySelect,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (query.page - 1) * query.perPage,
|
||||
take: query.perPage,
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, totalItems };
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.prisma.user.findFirst({ where: { ...this.alive(), id }, select: summarySelect });
|
||||
}
|
||||
|
||||
create(data: Prisma.UserCreateInput) {
|
||||
return this.prisma.user.create({ data, select: summarySelect });
|
||||
}
|
||||
|
||||
update(id: string, data: Prisma.UserUpdateInput) {
|
||||
return this.prisma.user.update({ where: { id }, data, select: summarySelect });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a user's role set atomically.
|
||||
*
|
||||
* Delete-then-insert inside one transaction, because a partial application
|
||||
* would briefly leave an operator with fewer — or worse, more — permissions
|
||||
* than intended.
|
||||
*/
|
||||
async setRoles(userId: string, roleIds: readonly string[]): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userRole.deleteMany({ where: { userId } }),
|
||||
this.prisma.userRole.createMany({
|
||||
data: roleIds.map((roleId) => ({ userId, roleId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
recordLogin(id: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
updatePasswordHash(id: string, passwordHash: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
countByRole(roleId: string) {
|
||||
return this.prisma.userRole.count({ where: { roleId } });
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthUserRow = NonNullable<Awaited<ReturnType<UsersRepository['findForAuthByEmail']>>>;
|
||||
export type UserRow = NonNullable<Awaited<ReturnType<UsersRepository['findById']>>>;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type CurrentUser,
|
||||
type OffsetPaginated,
|
||||
type Permission,
|
||||
type UserSummary,
|
||||
} from '@sport/types';
|
||||
import type { CreateUserInput, UpdateUserInput, UserListQuery } from '@sport/validation';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
|
||||
import { RolesRepository } from './roles.repository';
|
||||
import { UsersMapper } from './users.mapper';
|
||||
import { UsersRepository, type AuthUserRow } from './users.repository';
|
||||
|
||||
/**
|
||||
* Identity data: who exists, what they are, which roles they hold.
|
||||
*
|
||||
* AuthModule owns the *exchange* of credentials for tokens and the session
|
||||
* table; this module owns the accounts themselves. Auth reads through the
|
||||
* public surface below rather than querying `users` directly, which is what
|
||||
* keeps password handling and account management from bleeding into each other.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly repository: UsersRepository,
|
||||
private readonly rolesRepository: RolesRepository,
|
||||
private readonly mapper: UsersMapper,
|
||||
) {}
|
||||
|
||||
// ---- Consumed by AuthModule ---------------------------------------------
|
||||
|
||||
findForAuthByEmail(email: string): Promise<AuthUserRow | null> {
|
||||
return this.repository.findForAuthByEmail(email);
|
||||
}
|
||||
|
||||
findForAuthById(id: string): Promise<AuthUserRow | null> {
|
||||
return this.repository.findForAuthById(id);
|
||||
}
|
||||
|
||||
permissionsOf(row: AuthUserRow): Permission[] {
|
||||
return this.mapper.permissionsOf(row);
|
||||
}
|
||||
|
||||
toCurrentUser(row: AuthUserRow): CurrentUser {
|
||||
return this.mapper.toCurrentUser(row);
|
||||
}
|
||||
|
||||
async recordLogin(userId: string): Promise<void> {
|
||||
await this.repository.recordLogin(userId);
|
||||
}
|
||||
|
||||
async updatePasswordHash(userId: string, passwordHash: string): Promise<void> {
|
||||
await this.repository.updatePasswordHash(userId, passwordHash);
|
||||
}
|
||||
|
||||
// ---- Back-office user management ----------------------------------------
|
||||
|
||||
async list(query: UserListQuery): Promise<OffsetPaginated<UserSummary>> {
|
||||
const { items, totalItems } = await this.repository.list(query);
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage));
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.mapper.toSummary(item)),
|
||||
pageInfo: {
|
||||
page: query.page,
|
||||
perPage: query.perPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: query.page < totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<UserSummary> {
|
||||
const row = await this.repository.findById(id);
|
||||
if (!row) throw AppException.notFound('User');
|
||||
|
||||
return this.mapper.toSummary(row);
|
||||
}
|
||||
|
||||
async create(input: CreateUserInput, passwordHash: string): Promise<UserSummary> {
|
||||
const existing = await this.repository.findForAuthByEmail(input.email);
|
||||
if (existing) {
|
||||
throw AppException.conflict('An account with that email already exists.');
|
||||
}
|
||||
|
||||
await this.assertRolesExist(input.roleIds);
|
||||
|
||||
const user = await this.repository.create({
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
type: input.type,
|
||||
status: 'ACTIVE',
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone ?? null,
|
||||
roles: { createMany: { data: input.roleIds.map((roleId) => ({ roleId })) } },
|
||||
});
|
||||
|
||||
return this.getById(user.id);
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateUserInput): Promise<UserSummary> {
|
||||
const existing = await this.repository.findById(id);
|
||||
if (!existing) throw AppException.notFound('User');
|
||||
|
||||
if (input.roleIds) {
|
||||
await this.assertRolesExist(input.roleIds);
|
||||
await this.repository.setRoles(id, input.roleIds);
|
||||
}
|
||||
|
||||
await this.repository.update(id, {
|
||||
...(input.firstName === undefined ? {} : { firstName: input.firstName }),
|
||||
...(input.lastName === undefined ? {} : { lastName: input.lastName }),
|
||||
...(input.phone === undefined ? {} : { phone: input.phone ?? null }),
|
||||
...(input.status === undefined ? {} : { status: input.status }),
|
||||
...(input.type === undefined ? {} : { type: input.type }),
|
||||
});
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects unknown role ids rather than silently ignoring them.
|
||||
*
|
||||
* A create that quietly drops a role leaves an operator convinced they
|
||||
* granted access that was never granted — the worst possible failure mode for
|
||||
* a permissions screen.
|
||||
*/
|
||||
private async assertRolesExist(roleIds: readonly string[]): Promise<void> {
|
||||
if (roleIds.length === 0) return;
|
||||
|
||||
const found = await this.rolesRepository.findManyByIds(roleIds);
|
||||
if (found.length !== new Set(roleIds).size) {
|
||||
throw AppException.badRequest('One or more roles do not exist.', API_ERROR_CODES.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user