create-tigra 2.8.0 → 3.0.0
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 +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +42 -0
- package/template/server/.env.example.production +40 -0
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +15 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +40 -8
- package/template/server/src/config/env.ts +72 -28
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +10 -4
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- package/template/server/vitest.config.ts +43 -43
|
@@ -21,6 +21,18 @@ const PASSWORD_RESET_TTL = 3600; // 1 hour in seconds
|
|
|
21
21
|
const PASSWORD_RESET_PREFIX = 'pw-reset:';
|
|
22
22
|
const PASSWORD_RESET_USER_PREFIX = 'pw-reset-user:';
|
|
23
23
|
|
|
24
|
+
// --- Account lockout (scoped to email + IP) ---
|
|
25
|
+
// Failed-login attempts are tracked in Redis keyed by the (email, IP) PAIR.
|
|
26
|
+
// Keying by email alone let anyone lock a victim out remotely by spamming bad
|
|
27
|
+
// passwords at the victim's email (lockout DoS). Scoped to email+IP, an
|
|
28
|
+
// attacker only ever locks out their own address; distributed attempts are
|
|
29
|
+
// still throttled by the per-IP rate limit on the login route.
|
|
30
|
+
// Redis failures fail OPEN (no lockout) — consistent with ip-block.ts:
|
|
31
|
+
// availability over lockout, and rate limiting still applies.
|
|
32
|
+
const LOGIN_FAIL_PREFIX = 'login-fail:';
|
|
33
|
+
const LOGIN_LOCK_PREFIX = 'login-lock:';
|
|
34
|
+
const LOGIN_FAIL_WINDOW_SECONDS = 60 * 60; // failed attempts expire after 1 hour
|
|
35
|
+
|
|
24
36
|
// Account lockout configuration
|
|
25
37
|
const LOCKOUT_THRESHOLDS = [
|
|
26
38
|
{ attempts: 5, durationMs: 15 * 60 * 1000 }, // 5 failures → 15 min
|
|
@@ -37,6 +49,55 @@ function getLockoutDuration(failedAttempts: number): number | null {
|
|
|
37
49
|
return null;
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
function lockoutKey(prefix: string, email: string, ipAddress?: string): string {
|
|
53
|
+
return `${prefix}${email.toLowerCase()}:${ipAddress ?? 'unknown'}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function isLoginLocked(email: string, ipAddress?: string): Promise<boolean> {
|
|
57
|
+
try {
|
|
58
|
+
const locked = await getRedis().exists(lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress));
|
|
59
|
+
return locked === 1;
|
|
60
|
+
} catch {
|
|
61
|
+
return false; // fail open — Redis down must not block all logins
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function recordFailedLogin(email: string, ipAddress?: string): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
const redis = getRedis();
|
|
68
|
+
const failKey = lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress);
|
|
69
|
+
|
|
70
|
+
const attempts = await redis.incr(failKey);
|
|
71
|
+
if (attempts === 1) {
|
|
72
|
+
await redis.expire(failKey, LOGIN_FAIL_WINDOW_SECONDS);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lockDurationMs = getLockoutDuration(attempts);
|
|
76
|
+
if (lockDurationMs) {
|
|
77
|
+
await redis.set(
|
|
78
|
+
lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
|
|
79
|
+
'1',
|
|
80
|
+
'EX',
|
|
81
|
+
Math.ceil(lockDurationMs / 1000),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Non-critical: don't break the login error path if tracking fails
|
|
86
|
+
logger.warn('[AUTH] Failed to record failed login attempt');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function clearFailedLogins(email: string, ipAddress?: string): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
await getRedis().del(
|
|
93
|
+
lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress),
|
|
94
|
+
lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
// Non-critical: keys expire on their own
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
40
101
|
interface SanitizedUser {
|
|
41
102
|
id: string;
|
|
42
103
|
email: string;
|
|
@@ -167,12 +228,23 @@ export async function login(
|
|
|
167
228
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
168
229
|
}
|
|
169
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
|
+
|
|
170
237
|
// Verify password before restoring — don't restore on wrong password
|
|
171
238
|
const validPassword = await verifyPassword(input.password, deletedUser.password);
|
|
172
239
|
if (!validPassword) {
|
|
240
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
173
241
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
174
242
|
}
|
|
175
243
|
|
|
244
|
+
// Successful restore-login — clear the email+IP failure counter, same as
|
|
245
|
+
// the normal path.
|
|
246
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
247
|
+
|
|
176
248
|
// Restore account: clears deletedAt, sets isActive = true, resets lockout
|
|
177
249
|
await authRepo.restoreUser(deletedUser.id);
|
|
178
250
|
user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
|
|
@@ -184,7 +256,14 @@ export async function login(
|
|
|
184
256
|
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
185
257
|
}
|
|
186
258
|
|
|
187
|
-
// Check account lockout
|
|
259
|
+
// Check account lockout — scoped to this email+IP pair (Redis), so an
|
|
260
|
+
// attacker spamming bad passwords only locks out their OWN address and
|
|
261
|
+
// cannot remotely lock the real user out (lockout DoS).
|
|
262
|
+
if (await isLoginLocked(input.email, ipAddress)) {
|
|
263
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// DB-level lock (legacy data or manual admin lock) is still honored.
|
|
188
267
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
189
268
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
190
269
|
}
|
|
@@ -192,20 +271,13 @@ export async function login(
|
|
|
192
271
|
const valid = await verifyPassword(input.password, user.password);
|
|
193
272
|
|
|
194
273
|
if (!valid) {
|
|
195
|
-
|
|
196
|
-
const newAttempts = user.failedLoginAttempts + 1;
|
|
197
|
-
await authRepo.incrementFailedAttempts(user.id);
|
|
198
|
-
|
|
199
|
-
// Check if we need to lock the account
|
|
200
|
-
const lockDuration = getLockoutDuration(newAttempts);
|
|
201
|
-
if (lockDuration) {
|
|
202
|
-
await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
|
|
203
|
-
}
|
|
204
|
-
|
|
274
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
205
275
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
206
276
|
}
|
|
207
277
|
|
|
208
|
-
// Successful login —
|
|
278
|
+
// Successful login — clear the email+IP failure counter and any stale
|
|
279
|
+
// DB-level lockout state from before lockout moved to Redis.
|
|
280
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
209
281
|
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
|
|
210
282
|
await authRepo.resetFailedAttempts(user.id);
|
|
211
283
|
}
|
|
@@ -274,6 +346,25 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
|
|
|
274
346
|
});
|
|
275
347
|
|
|
276
348
|
if (!rotated) {
|
|
349
|
+
// REUSE DETECTED: the token existed when we looked it up but was consumed
|
|
350
|
+
// by a concurrent request before we could rotate it — two parties presented
|
|
351
|
+
// the same single-use token. We cannot tell which one is the attacker, and
|
|
352
|
+
// whichever redeemed it first already holds a freshly rotated valid token.
|
|
353
|
+
// Revoke the entire token family: every refresh token and session for this
|
|
354
|
+
// user. Both legit client and attacker must re-authenticate.
|
|
355
|
+
//
|
|
356
|
+
// NOTE: this covers the *detectable* reuse path. Refresh tokens are opaque
|
|
357
|
+
// random UUIDs (no JWT claims), so a token that is not found in the DB at
|
|
358
|
+
// all (deleted in an earlier rotation) cannot be attributed to a user, and
|
|
359
|
+
// the initial not-found lookup above can only reject it. Full replay
|
|
360
|
+
// attribution would require tombstoning consumed tokens (e.g. a revokedAt
|
|
361
|
+
// column) instead of deleting them — a schema change out of scope here.
|
|
362
|
+
logger.warn(
|
|
363
|
+
{ userId: user.id },
|
|
364
|
+
'[AUTH] Refresh token reuse detected — revoking all sessions and refresh tokens for user',
|
|
365
|
+
);
|
|
366
|
+
await authRepo.deleteRefreshTokensByUserId(user.id);
|
|
367
|
+
await sessionRepository.deleteAllUserSessions(user.id);
|
|
277
368
|
throw new UnauthorizedError('Refresh token already used', 'INVALID_REFRESH_TOKEN');
|
|
278
369
|
}
|
|
279
370
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
// Mock environment variables for testing
|
|
3
|
+
// Mock environment variables for testing.
|
|
4
|
+
// This setup file runs BEFORE any test file is imported (vitest setupFiles),
|
|
5
|
+
// so these must form a COMPLETE valid env: src/config/env.ts validates on
|
|
6
|
+
// import and calls process.exit(1) on failure, killing the whole suite before
|
|
7
|
+
// a single test runs.
|
|
4
8
|
process.env.NODE_ENV = 'test';
|
|
5
9
|
process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/test_db';
|
|
6
10
|
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
@@ -9,6 +13,9 @@ process.env.JWT_ACCESS_EXPIRY = '15m';
|
|
|
9
13
|
process.env.JWT_REFRESH_EXPIRY = '7d';
|
|
10
14
|
process.env.PORT = '3000';
|
|
11
15
|
process.env.HOST = '0.0.0.0';
|
|
16
|
+
// REQUIRE_USER_VERIFICATION defaults to 'true', and env.ts exits when it is
|
|
17
|
+
// enabled without RESEND_API_KEY. Tests don't send email — disable it.
|
|
18
|
+
process.env.REQUIRE_USER_VERIFICATION = 'false';
|
|
12
19
|
|
|
13
20
|
// Test user data
|
|
14
21
|
export const testUsers = {
|
|
@@ -59,15 +66,28 @@ export const testUsers = {
|
|
|
59
66
|
},
|
|
60
67
|
};
|
|
61
68
|
|
|
62
|
-
// Test refresh token data
|
|
69
|
+
// Test refresh token data — fields must match the Prisma RefreshToken model
|
|
70
|
+
// exactly (id, token, userId, sessionId, expiresAt, createdAt)
|
|
63
71
|
export const testRefreshToken = {
|
|
64
72
|
id: 'test-token-id-1',
|
|
65
73
|
token: 'test-refresh-token-uuid',
|
|
66
74
|
userId: testUsers.validUser.id,
|
|
75
|
+
sessionId: null,
|
|
67
76
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
|
68
77
|
createdAt: new Date(),
|
|
69
78
|
};
|
|
70
79
|
|
|
80
|
+
// Test session data — fields must match the Prisma Session model exactly
|
|
81
|
+
export const testSession = {
|
|
82
|
+
id: 'test-session-id-1',
|
|
83
|
+
userId: testUsers.validUser.id,
|
|
84
|
+
deviceInfo: null,
|
|
85
|
+
ipAddress: null,
|
|
86
|
+
lastActiveAt: new Date('2024-01-01T00:00:00Z'),
|
|
87
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
88
|
+
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
89
|
+
};
|
|
90
|
+
|
|
71
91
|
// Reset all mocks before each test
|
|
72
92
|
export const resetMocks = (): void => {
|
|
73
93
|
vi.clearAllMocks();
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
|
|
4
|
-
export default defineConfig({
|
|
5
|
-
test: {
|
|
6
|
-
globals: true,
|
|
7
|
-
environment: 'node',
|
|
8
|
-
coverage: {
|
|
9
|
-
provider: 'v8',
|
|
10
|
-
reporter: ['text', 'json', 'html', 'lcov'],
|
|
11
|
-
exclude: [
|
|
12
|
-
'node_modules/**',
|
|
13
|
-
'dist/**',
|
|
14
|
-
'**/*.config.ts',
|
|
15
|
-
'**/*.config.js',
|
|
16
|
-
'**/test/**',
|
|
17
|
-
'**/__tests__/**',
|
|
18
|
-
'prisma/**',
|
|
19
|
-
'scripts/**',
|
|
20
|
-
],
|
|
21
|
-
thresholds: {
|
|
22
|
-
lines: 80,
|
|
23
|
-
functions: 80,
|
|
24
|
-
branches: 80,
|
|
25
|
-
statements: 80,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
setupFiles: ['./src/test/setup.ts'],
|
|
29
|
-
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
|
30
|
-
exclude: ['node_modules', 'dist'],
|
|
31
|
-
testTimeout: 10000,
|
|
32
|
-
hookTimeout: 10000,
|
|
33
|
-
},
|
|
34
|
-
resolve: {
|
|
35
|
-
alias: {
|
|
36
|
-
'@': resolve(__dirname, './src'),
|
|
37
|
-
'@modules': resolve(__dirname, './src/modules'),
|
|
38
|
-
'@libs': resolve(__dirname, './src/libs'),
|
|
39
|
-
'@config': resolve(__dirname, './src/config'),
|
|
40
|
-
'@shared': resolve(__dirname, './src/shared'),
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
});
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'node',
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
11
|
+
exclude: [
|
|
12
|
+
'node_modules/**',
|
|
13
|
+
'dist/**',
|
|
14
|
+
'**/*.config.ts',
|
|
15
|
+
'**/*.config.js',
|
|
16
|
+
'**/test/**',
|
|
17
|
+
'**/__tests__/**',
|
|
18
|
+
'prisma/**',
|
|
19
|
+
'scripts/**',
|
|
20
|
+
],
|
|
21
|
+
thresholds: {
|
|
22
|
+
lines: 80,
|
|
23
|
+
functions: 80,
|
|
24
|
+
branches: 80,
|
|
25
|
+
statements: 80,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
setupFiles: ['./src/test/setup.ts'],
|
|
29
|
+
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
|
30
|
+
exclude: ['node_modules', 'dist'],
|
|
31
|
+
testTimeout: 10000,
|
|
32
|
+
hookTimeout: 10000,
|
|
33
|
+
},
|
|
34
|
+
resolve: {
|
|
35
|
+
alias: {
|
|
36
|
+
'@': resolve(__dirname, './src'),
|
|
37
|
+
'@modules': resolve(__dirname, './src/modules'),
|
|
38
|
+
'@libs': resolve(__dirname, './src/libs'),
|
|
39
|
+
'@config': resolve(__dirname, './src/config'),
|
|
40
|
+
'@shared': resolve(__dirname, './src/shared'),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|