create-tigra 3.0.3 → 3.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/template/client/Dockerfile +11 -1
- package/template/client/package.json +1 -1
- package/template/client/scripts/next-dev.cjs +47 -0
- package/template/client/src/instrumentation-client.ts +29 -21
- package/template/client/src/instrumentation.ts +21 -17
- package/template/server/Dockerfile +9 -3
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +25 -41
- package/template/server/src/modules/auth/auth.repo.ts +0 -12
- package/template/server/src/modules/auth/auth.service.ts +29 -57
package/README.md
CHANGED
|
@@ -82,6 +82,10 @@ cd my-app/server
|
|
|
82
82
|
docker compose --profile tools up -d
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
## Security and Migration Notes
|
|
86
|
+
|
|
87
|
+
- [Account deactivation login reactivation: issue, fix, and existing-project migration](docs/account-deactivation-login-reactivation.md)
|
|
88
|
+
|
|
85
89
|
## License
|
|
86
90
|
|
|
87
91
|
MIT
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
1
2
|
# ===================================================================
|
|
2
3
|
# Multi-stage Dockerfile for Next.js Production Deployment
|
|
3
4
|
# Optimized for Coolify, Docker Compose, and Kubernetes
|
|
4
5
|
# Requires `output: "standalone"` in next.config.ts
|
|
5
6
|
# ===================================================================
|
|
7
|
+
# BuildKit cache mount (--mount=type=cache) keeps the npm download cache warm
|
|
8
|
+
# across builds, so a lockfile/dep-change rebuild re-installs from cache
|
|
9
|
+
# (measured ~2.8x: 49s->18s on a real create-tigra client) instead of
|
|
10
|
+
# re-downloading every tarball. Requires the `# syntax` line above (BuildKit).
|
|
6
11
|
|
|
7
12
|
# ===================================================================
|
|
8
13
|
# Stage 1: Dependencies (cached layer)
|
|
@@ -15,7 +20,8 @@ WORKDIR /app
|
|
|
15
20
|
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
16
21
|
|
|
17
22
|
# Install dependencies (use the lockfile that exists)
|
|
18
|
-
RUN
|
|
23
|
+
RUN --mount=type=cache,target=/root/.npm \
|
|
24
|
+
if [ -f pnpm-lock.yaml ]; then \
|
|
19
25
|
npm install -g pnpm && pnpm install --frozen-lockfile; \
|
|
20
26
|
elif [ -f yarn.lock ]; then \
|
|
21
27
|
yarn install --frozen-lockfile; \
|
|
@@ -49,6 +55,10 @@ ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
|
|
49
55
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
50
56
|
|
|
51
57
|
# Build the application (outputs to .next/standalone)
|
|
58
|
+
# NOTE: a `--mount=type=cache,target=/app/.next/cache` was tested here and REMOVED —
|
|
59
|
+
# it measured SLOWER (next build 29s→58s on a real create-tigra client), because Next's
|
|
60
|
+
# persistent-cache serialization overhead exceeds the incremental savings for small/mid
|
|
61
|
+
# apps. Re-add it only for a genuinely large client after measuring a win.
|
|
52
62
|
RUN npm run build
|
|
53
63
|
|
|
54
64
|
# ===================================================================
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_OLD_SPACE_SIZE = "768";
|
|
7
|
+
const nextBin = require.resolve("next/dist/bin/next");
|
|
8
|
+
|
|
9
|
+
function applyOldSpaceLimit(nodeOptions, sizeMb) {
|
|
10
|
+
if (/(^|\s)--max[-_]old[-_]space[-_]size(=|\s|$)/.test(nodeOptions)) {
|
|
11
|
+
return nodeOptions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return [nodeOptions, `--max-old-space-size=${sizeMb}`].filter(Boolean).join(" ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const env = { ...process.env };
|
|
18
|
+
env.NEXT_DISABLE_MEM_OVERRIDE = "1";
|
|
19
|
+
env.NODE_OPTIONS = applyOldSpaceLimit(
|
|
20
|
+
env.NODE_OPTIONS || "",
|
|
21
|
+
env.NEXT_DEV_MAX_OLD_SPACE_SIZE || DEFAULT_MAX_OLD_SPACE_SIZE,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const args = [
|
|
25
|
+
nextBin,
|
|
26
|
+
"dev",
|
|
27
|
+
"--webpack",
|
|
28
|
+
"--disable-source-maps",
|
|
29
|
+
"--no-server-fast-refresh",
|
|
30
|
+
...process.argv.slice(2),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const child = spawn(process.execPath, args, { stdio: "inherit", env });
|
|
34
|
+
|
|
35
|
+
child.on("error", (error) => {
|
|
36
|
+
console.error(error);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
child.on("exit", (code, signal) => {
|
|
41
|
+
if (signal) {
|
|
42
|
+
process.kill(process.pid, signal);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.exit(code ?? 0);
|
|
47
|
+
});
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser
|
|
2
|
+
* Browser instrumentation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* no-op and ships nothing that phones home.
|
|
7
|
-
*
|
|
8
|
-
* `process.env.NEXT_PUBLIC_*` is inlined at build time by Next — reference it
|
|
9
|
-
* literally (not via a computed key) so the value is statically replaced.
|
|
10
|
-
*
|
|
11
|
-
* Like the server side, this stays runtime + env-gated: we do NOT wrap
|
|
12
|
-
* next.config.ts with `withSentryConfig` (that would couple `next build` to a
|
|
13
|
-
* source-map upload auth token). withSentryConfig is the opt-in upgrade.
|
|
4
|
+
* Keep local dev cold: do not import @sentry/nextjs unless production has a
|
|
5
|
+
* public DSN. This avoids compiling the Sentry SDK for every local dev server.
|
|
14
6
|
*/
|
|
15
|
-
|
|
7
|
+
type SentryNext = typeof import('@sentry/nextjs');
|
|
8
|
+
type RouterTransitionStart = SentryNext['captureRouterTransitionStart'];
|
|
16
9
|
|
|
17
10
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
|
11
|
+
const sentryEnabled = process.env.NODE_ENV === 'production' && Boolean(dsn);
|
|
12
|
+
|
|
13
|
+
let sentryPromise: Promise<SentryNext> | null = null;
|
|
14
|
+
|
|
15
|
+
function loadSentry(): Promise<SentryNext | null> {
|
|
16
|
+
if (!sentryEnabled || !dsn) return Promise.resolve(null);
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
sentryPromise ??= import('@sentry/nextjs').then((Sentry) => {
|
|
19
|
+
Sentry.init({
|
|
20
|
+
dsn,
|
|
21
|
+
tracesSampleRate: process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
|
|
22
|
+
? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
|
|
23
|
+
: 0.1,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return Sentry;
|
|
25
27
|
});
|
|
28
|
+
|
|
29
|
+
return sentryPromise;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
export const onRouterTransitionStart =
|
|
32
|
+
void loadSentry();
|
|
33
|
+
|
|
34
|
+
export const onRouterTransitionStart = ((...args: Parameters<RouterTransitionStart>) => {
|
|
35
|
+
void loadSentry().then((Sentry) => {
|
|
36
|
+
Sentry?.captureRouterTransitionStart(...args);
|
|
37
|
+
});
|
|
38
|
+
}) as RouterTransitionStart;
|
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server/Edge runtime instrumentation
|
|
2
|
+
* Server/Edge runtime instrumentation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* with the browser), so with no DSN this is a clean no-op and `next build`
|
|
7
|
-
* succeeds with zero Sentry credentials.
|
|
8
|
-
*
|
|
9
|
-
* NOTE: we deliberately do NOT wrap next.config.ts with `withSentryConfig`.
|
|
10
|
-
* That adds build-time source-map upload which requires a SENTRY_AUTH_TOKEN and
|
|
11
|
-
* would couple `next build` to credentials. Source-map upload is the opt-in
|
|
12
|
-
* upgrade — add `withSentryConfig` + an auth token when you want it. This setup
|
|
13
|
-
* stays purely runtime + env-gated.
|
|
4
|
+
* Keep local dev cold: importing @sentry/nextjs at module top-level makes Next
|
|
5
|
+
* compile the Sentry SDK before the first page, even with no DSN.
|
|
14
6
|
*/
|
|
15
|
-
|
|
7
|
+
type SentryNext = typeof import('@sentry/nextjs');
|
|
8
|
+
type RequestErrorHandler = SentryNext['captureRequestError'];
|
|
16
9
|
|
|
17
10
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN ?? process.env.SENTRY_DSN;
|
|
11
|
+
const sentryEnabled = process.env.NODE_ENV === 'production' && Boolean(dsn);
|
|
18
12
|
|
|
19
13
|
const tracesSampleRate = process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE
|
|
20
14
|
? Number(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE)
|
|
21
15
|
: 0.1;
|
|
22
16
|
|
|
17
|
+
let sentryPromise: Promise<SentryNext> | null = null;
|
|
18
|
+
|
|
19
|
+
function loadSentry(): Promise<SentryNext | null> {
|
|
20
|
+
if (!sentryEnabled) return Promise.resolve(null);
|
|
21
|
+
sentryPromise ??= import('@sentry/nextjs');
|
|
22
|
+
return sentryPromise;
|
|
23
|
+
}
|
|
24
|
+
|
|
23
25
|
export async function register(): Promise<void> {
|
|
24
|
-
|
|
25
|
-
if (!dsn) return;
|
|
26
|
+
const Sentry = await loadSentry();
|
|
27
|
+
if (!Sentry || !dsn) return;
|
|
26
28
|
|
|
27
29
|
if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {
|
|
28
30
|
Sentry.init({
|
|
@@ -33,6 +35,8 @@ export async function register(): Promise<void> {
|
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
export const onRequestError = (async (...args: Parameters<RequestErrorHandler>) => {
|
|
39
|
+
const Sentry = await loadSentry();
|
|
40
|
+
if (!Sentry) return;
|
|
41
|
+
return Sentry.captureRequestError(...args);
|
|
42
|
+
}) as RequestErrorHandler;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
1
2
|
# ===================================================================
|
|
2
3
|
# Multi-stage Dockerfile for Production Deployment
|
|
3
4
|
# Optimized for Coolify, Docker Compose, and Kubernetes
|
|
4
5
|
# ===================================================================
|
|
6
|
+
# BuildKit cache mounts (--mount=type=cache) keep the npm download cache warm
|
|
7
|
+
# ACROSS builds, so a lockfile-change rebuild re-installs without re-downloading.
|
|
8
|
+
# Requires the `# syntax` line above; auto-enabled on Docker 23+ / any BuildKit.
|
|
5
9
|
|
|
6
10
|
# ===================================================================
|
|
7
11
|
# Stage 1: Dependencies (cached layer)
|
|
@@ -15,7 +19,8 @@ WORKDIR /app
|
|
|
15
19
|
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
16
20
|
|
|
17
21
|
# Install dependencies (use the lockfile that exists)
|
|
18
|
-
RUN
|
|
22
|
+
RUN --mount=type=cache,target=/root/.npm \
|
|
23
|
+
if [ -f pnpm-lock.yaml ]; then \
|
|
19
24
|
npm install -g pnpm && pnpm install --prod --frozen-lockfile; \
|
|
20
25
|
elif [ -f yarn.lock ]; then \
|
|
21
26
|
yarn install --production --frozen-lockfile; \
|
|
@@ -32,7 +37,7 @@ RUN if [ -f pnpm-lock.yaml ]; then \
|
|
|
32
37
|
# --no-save keeps package.json and the lockfile untouched. Because this stage
|
|
33
38
|
# runs on the same Alpine (musl) base as the runtime stage, the engine binaries
|
|
34
39
|
# downloaded here are the correct ones for production.
|
|
35
|
-
RUN npm install --no-save --no-audit --no-fund \
|
|
40
|
+
RUN --mount=type=cache,target=/root/.npm npm install --no-save --no-audit --no-fund \
|
|
36
41
|
"prisma@$(node -p "require('@prisma/client/package.json').version")"
|
|
37
42
|
|
|
38
43
|
# ===================================================================
|
|
@@ -50,7 +55,8 @@ WORKDIR /app
|
|
|
50
55
|
# Copy package files and install ALL dependencies (including dev)
|
|
51
56
|
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
|
52
57
|
|
|
53
|
-
RUN
|
|
58
|
+
RUN --mount=type=cache,target=/root/.npm \
|
|
59
|
+
if [ -f pnpm-lock.yaml ]; then \
|
|
54
60
|
npm install -g pnpm && pnpm install --frozen-lockfile; \
|
|
55
61
|
elif [ -f yarn.lock ]; then \
|
|
56
62
|
yarn install --frozen-lockfile; \
|
|
@@ -137,6 +137,9 @@ describe('Auth Service', () => {
|
|
|
137
137
|
|
|
138
138
|
// Act & Assert
|
|
139
139
|
await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
|
|
140
|
+
await expect(authService.register(validRegisterInput)).rejects.toThrow(
|
|
141
|
+
'An account with this email was recently deleted and cannot be registered yet.',
|
|
142
|
+
);
|
|
140
143
|
expect(authRepo.createUser).not.toHaveBeenCalled();
|
|
141
144
|
});
|
|
142
145
|
});
|
|
@@ -193,12 +196,33 @@ describe('Auth Service', () => {
|
|
|
193
196
|
it('should throw UnauthorizedError if user not found', async () => {
|
|
194
197
|
// Arrange
|
|
195
198
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
196
|
-
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
|
|
197
199
|
|
|
198
200
|
// Act & Assert
|
|
199
201
|
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
200
202
|
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
203
|
+
expect(authRepo.findDeletedUserByEmail).not.toHaveBeenCalled();
|
|
204
|
+
expect(verifyPassword).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should not restore or authenticate a soft-deleted account with correct credentials', async () => {
|
|
208
|
+
// findUserByEmail excludes soft-deleted records, so login must stop before
|
|
209
|
+
// password verification, token creation, or session creation.
|
|
210
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
211
|
+
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue({
|
|
212
|
+
...testUsers.validUser,
|
|
213
|
+
deletedAt: new Date('2024-02-01T00:00:00Z'),
|
|
214
|
+
isActive: false,
|
|
215
|
+
});
|
|
216
|
+
vi.mocked(verifyPassword).mockResolvedValue(true);
|
|
217
|
+
|
|
218
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
219
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
|
|
220
|
+
expect(authRepo.findDeletedUserByEmail).not.toHaveBeenCalled();
|
|
201
221
|
expect(verifyPassword).not.toHaveBeenCalled();
|
|
222
|
+
expect(authLib.signAccessToken).not.toHaveBeenCalled();
|
|
223
|
+
expect(authLib.generateRefreshToken).not.toHaveBeenCalled();
|
|
224
|
+
expect(sessionRepository.createSession).not.toHaveBeenCalled();
|
|
225
|
+
expect(authRepo.createRefreshToken).not.toHaveBeenCalled();
|
|
202
226
|
});
|
|
203
227
|
|
|
204
228
|
it('should throw ForbiddenError if account is not activated', async () => {
|
|
@@ -325,46 +349,6 @@ describe('Auth Service', () => {
|
|
|
325
349
|
});
|
|
326
350
|
});
|
|
327
351
|
|
|
328
|
-
describe('login — soft-deleted account restore path (lockout)', () => {
|
|
329
|
-
const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
|
|
330
|
-
const attackerIp = '203.0.113.7';
|
|
331
|
-
const softDeletedUser = {
|
|
332
|
-
...testUsers.validUser,
|
|
333
|
-
deletedAt: new Date('2024-02-01T00:00:00Z'),
|
|
334
|
-
isActive: false,
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
it('should record a failed attempt against the email+IP pair on wrong password for a soft-deleted account', async () => {
|
|
338
|
-
// Arrange
|
|
339
|
-
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
340
|
-
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
|
|
341
|
-
vi.mocked(verifyPassword).mockResolvedValue(false);
|
|
342
|
-
mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
|
|
343
|
-
|
|
344
|
-
// Act & Assert
|
|
345
|
-
await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
|
|
346
|
-
'Invalid email or password',
|
|
347
|
-
);
|
|
348
|
-
expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
|
|
349
|
-
expect(authRepo.restoreUser).not.toHaveBeenCalled();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('should reject a locked email+IP pair on the soft-delete path before verifying the password', async () => {
|
|
353
|
-
// Arrange
|
|
354
|
-
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
355
|
-
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
|
|
356
|
-
mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
|
|
357
|
-
|
|
358
|
-
// Act & Assert
|
|
359
|
-
await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
|
|
360
|
-
'Invalid email or password',
|
|
361
|
-
);
|
|
362
|
-
expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
|
|
363
|
-
expect(verifyPassword).not.toHaveBeenCalled();
|
|
364
|
-
expect(authRepo.restoreUser).not.toHaveBeenCalled();
|
|
365
|
-
});
|
|
366
|
-
});
|
|
367
|
-
|
|
368
352
|
describe('refresh', () => {
|
|
369
353
|
const validRefreshToken = 'valid-refresh-token';
|
|
370
354
|
|
|
@@ -13,18 +13,6 @@ export async function findDeletedUserByEmail(email: string): Promise<User | null
|
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export async function restoreUser(userId: string): Promise<void> {
|
|
17
|
-
await prisma.user.update({
|
|
18
|
-
where: { id: userId },
|
|
19
|
-
data: {
|
|
20
|
-
deletedAt: null,
|
|
21
|
-
isActive: true,
|
|
22
|
-
failedLoginAttempts: 0,
|
|
23
|
-
lockedUntil: null,
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
export async function findUserById(id: string): Promise<User | null> {
|
|
29
17
|
return prisma.user.findUnique({
|
|
30
18
|
where: { id, deletedAt: null },
|
|
@@ -157,11 +157,11 @@ export async function register(
|
|
|
157
157
|
throw new ConflictError('Email already registered', 'EMAIL_ALREADY_EXISTS');
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
//
|
|
160
|
+
// Keep the email reserved while the soft-deleted account is retained.
|
|
161
161
|
const deletedUser = await authRepo.findDeletedUserByEmail(input.email);
|
|
162
162
|
if (deletedUser) {
|
|
163
163
|
throw new ConflictError(
|
|
164
|
-
'An account with this email was recently deleted
|
|
164
|
+
'An account with this email was recently deleted and cannot be registered yet.',
|
|
165
165
|
'EMAIL_ALREADY_EXISTS',
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -219,68 +219,40 @@ export async function login(
|
|
|
219
219
|
deviceInfo?: string,
|
|
220
220
|
ipAddress?: string,
|
|
221
221
|
): Promise<AuthResult> {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// If no active user found, check for a soft-deleted account that can be restored
|
|
222
|
+
const user = await authRepo.findUserByEmail(input.email);
|
|
225
223
|
if (!user) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Same email+IP lockout as the normal path — the restore flow must not be
|
|
232
|
-
// a brute-force side door around the lockout.
|
|
233
|
-
if (await isLoginLocked(input.email, ipAddress)) {
|
|
234
|
-
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Verify password before restoring — don't restore on wrong password
|
|
238
|
-
const validPassword = await verifyPassword(input.password, deletedUser.password);
|
|
239
|
-
if (!validPassword) {
|
|
240
|
-
await recordFailedLogin(input.email, ipAddress);
|
|
241
|
-
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Successful restore-login — clear the email+IP failure counter, same as
|
|
245
|
-
// the normal path.
|
|
246
|
-
await clearFailedLogins(input.email, ipAddress);
|
|
247
|
-
|
|
248
|
-
// Restore account: clears deletedAt, sets isActive = true, resets lockout
|
|
249
|
-
await authRepo.restoreUser(deletedUser.id);
|
|
250
|
-
user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
|
|
251
|
-
} else {
|
|
252
|
-
// Normal login flow for active accounts
|
|
224
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
225
|
+
}
|
|
253
226
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
227
|
+
// Distinct error for inactive accounts so the client can show proper messaging
|
|
228
|
+
if (!user.isActive) {
|
|
229
|
+
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
230
|
+
}
|
|
258
231
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
232
|
+
// Check account lockout — scoped to this email+IP pair (Redis), so an
|
|
233
|
+
// attacker spamming bad passwords only locks out their OWN address and
|
|
234
|
+
// cannot remotely lock the real user out (lockout DoS).
|
|
235
|
+
if (await isLoginLocked(input.email, ipAddress)) {
|
|
236
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
237
|
+
}
|
|
265
238
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
239
|
+
// DB-level lock (legacy data or manual admin lock) is still honored.
|
|
240
|
+
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
241
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
242
|
+
}
|
|
270
243
|
|
|
271
|
-
|
|
244
|
+
const valid = await verifyPassword(input.password, user.password);
|
|
272
245
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
246
|
+
if (!valid) {
|
|
247
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
248
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
249
|
+
}
|
|
277
250
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
251
|
+
// Successful login — clear the email+IP failure counter and any stale
|
|
252
|
+
// DB-level lockout state from before lockout moved to Redis.
|
|
253
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
254
|
+
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
|
|
255
|
+
await authRepo.resetFailedAttempts(user.id);
|
|
284
256
|
}
|
|
285
257
|
|
|
286
258
|
const accessToken = signAccessToken({
|