create-tigra 2.7.2 → 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 +44 -5
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/05-security.md +1 -1
- 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 +15 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/app/globals.css +8 -0
- package/template/client/src/app/layout.tsx +7 -1
- package/template/client/src/app/providers.tsx +5 -4
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +3 -0
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +5 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +27 -44
- package/template/client/src/features/auth/hooks/useAuth.ts +6 -2
- package/template/client/src/features/auth/hooks/useCurrentUser.ts +50 -0
- 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 -76
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +41 -15
- package/template/server/src/config/env.ts +72 -28
- package/template/server/src/config/rate-limit.config.ts +17 -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 +15 -18
- 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.controller.ts +16 -5
- 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
|
@@ -3,8 +3,15 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
3
3
|
import { env } from '@config/env.js';
|
|
4
4
|
import { prisma } from '@libs/prisma.js';
|
|
5
5
|
import { UnauthorizedError, ForbiddenError, BadRequestError } from '@shared/errors/errors.js';
|
|
6
|
+
import { clearAuthCookies } from '@libs/cookies.js';
|
|
7
|
+
import { parseDurationMs } from '@libs/duration.js';
|
|
6
8
|
import type { JwtPayload, UserRole } from '@shared/types/index.js';
|
|
7
9
|
|
|
10
|
+
// Re-export so existing consumers of `parseDurationMs` from '@libs/auth.js'
|
|
11
|
+
// keep working. The implementation lives in the import-free leaf module
|
|
12
|
+
// duration.ts to avoid the cookies.ts ↔ auth.ts circular import (TDZ crash).
|
|
13
|
+
export { parseDurationMs } from '@libs/duration.js';
|
|
14
|
+
|
|
8
15
|
let app: FastifyInstance | null = null;
|
|
9
16
|
|
|
10
17
|
export function initAuth(fastify: FastifyInstance): void {
|
|
@@ -29,22 +36,6 @@ export function generateRefreshToken(): string {
|
|
|
29
36
|
return uuidv4();
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
export function parseDurationMs(duration: string, fallbackMs: number): number {
|
|
33
|
-
const match = duration.match(/^(\d+)([smhd])$/);
|
|
34
|
-
if (!match) return fallbackMs;
|
|
35
|
-
|
|
36
|
-
const value = parseInt(match[1], 10);
|
|
37
|
-
const unit = match[2];
|
|
38
|
-
const multipliers: Record<string, number> = {
|
|
39
|
-
s: 1000,
|
|
40
|
-
m: 60 * 1000,
|
|
41
|
-
h: 60 * 60 * 1000,
|
|
42
|
-
d: 24 * 60 * 60 * 1000,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
return value * multipliers[unit];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
39
|
export function getRefreshTokenExpiresAt(): Date {
|
|
49
40
|
const ms = parseDurationMs(env.JWT_REFRESH_EXPIRY, 7 * 24 * 60 * 60 * 1000);
|
|
50
41
|
return new Date(Date.now() + ms);
|
|
@@ -52,7 +43,7 @@ export function getRefreshTokenExpiresAt(): Date {
|
|
|
52
43
|
|
|
53
44
|
export async function authenticate(
|
|
54
45
|
request: FastifyRequest,
|
|
55
|
-
|
|
46
|
+
reply: FastifyReply,
|
|
56
47
|
): Promise<void> {
|
|
57
48
|
try {
|
|
58
49
|
await request.jwtVerify();
|
|
@@ -60,16 +51,22 @@ export async function authenticate(
|
|
|
60
51
|
throw new UnauthorizedError('Invalid or expired token');
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
// Verify user still exists, is active, and not soft-deleted
|
|
54
|
+
// Verify user still exists, is active, and not soft-deleted.
|
|
55
|
+
// When the session is definitively dead (user gone/deleted/inactive), clear auth
|
|
56
|
+
// cookies on the response so the browser stops replaying stale credentials.
|
|
57
|
+
// Without this, middleware keeps seeing the (still-unexpired) JWT cookie and
|
|
58
|
+
// bounces /login → /dashboard → 401 → /login in an infinite loop.
|
|
64
59
|
const user = await prisma.user.findUnique({
|
|
65
60
|
where: { id: request.user.userId },
|
|
66
61
|
select: { isActive: true, deletedAt: true },
|
|
67
62
|
});
|
|
68
63
|
|
|
69
64
|
if (!user || user.deletedAt) {
|
|
65
|
+
clearAuthCookies(reply);
|
|
70
66
|
throw new UnauthorizedError('Account is deactivated or deleted');
|
|
71
67
|
}
|
|
72
68
|
if (!user.isActive) {
|
|
69
|
+
clearAuthCookies(reply);
|
|
73
70
|
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
74
71
|
}
|
|
75
72
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parsing — LEAF MODULE.
|
|
3
|
+
*
|
|
4
|
+
* This file must have ZERO imports. It exists to break the circular import
|
|
5
|
+
* between cookies.ts and auth.ts: cookies.ts calls parseDurationMs at module
|
|
6
|
+
* top-level, so if it imported the function from auth.ts (which imports
|
|
7
|
+
* clearAuthCookies from cookies.ts), the cycle left auth.ts partially
|
|
8
|
+
* initialized and crashed module evaluation with
|
|
9
|
+
* "parseDurationMs is not a function" (TDZ). Keep this module dependency-free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a duration string like "15m", "7d", "30s", "12h" into milliseconds.
|
|
14
|
+
* Returns `fallbackMs` when the string doesn't match the expected format.
|
|
15
|
+
*/
|
|
16
|
+
export function parseDurationMs(duration: string, fallbackMs: number): number {
|
|
17
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
18
|
+
if (!match) return fallbackMs;
|
|
19
|
+
|
|
20
|
+
const value = parseInt(match[1], 10);
|
|
21
|
+
const unit = match[2];
|
|
22
|
+
const multipliers: Record<string, number> = {
|
|
23
|
+
s: 1000,
|
|
24
|
+
m: 60 * 1000,
|
|
25
|
+
h: 60 * 60 * 1000,
|
|
26
|
+
d: 24 * 60 * 60 * 1000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return value * multipliers[unit];
|
|
30
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { env } from '@config/env.js';
|
|
16
17
|
import { prisma } from '@libs/prisma.js';
|
|
17
18
|
import { getRedis } from '@libs/redis.js';
|
|
18
19
|
import { logger } from '@libs/logger.js';
|
|
@@ -23,10 +24,15 @@ const BLOCKED_IPS_KEY = 'blocked_ips';
|
|
|
23
24
|
const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
|
|
24
25
|
const VIOLATION_PREFIX = 'rl_violations:';
|
|
25
26
|
|
|
26
|
-
// Auto-block thresholds
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Auto-block thresholds — env-configurable (see .env.example).
|
|
28
|
+
// The threshold default is deliberately high (20): auto-block must catch
|
|
29
|
+
// SUSTAINED abuse, not a single burst. Rate-limit counting happens before
|
|
30
|
+
// validation, so a retry-looping but legitimate client (or a NAT'd office
|
|
31
|
+
// sharing one egress IP) can rack up violations quickly — see the self-ban
|
|
32
|
+
// interaction note in src/config/rate-limit.config.ts before lowering it.
|
|
33
|
+
const AUTO_BLOCK_THRESHOLD = env.IP_AUTO_BLOCK_THRESHOLD; // violations before auto-block (default 20)
|
|
34
|
+
const AUTO_BLOCK_WINDOW_SECONDS = env.IP_AUTO_BLOCK_WINDOW_SECONDS; // sliding window (default 300 = 5 min)
|
|
35
|
+
const AUTO_BLOCK_DURATION_SECONDS = env.IP_AUTO_BLOCK_DURATION_SECONDS; // block duration (default 3600 = 1 hour)
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Sync all permanent blocked IPs from DB to Redis.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF defense-in-depth — Origin verification for state-changing requests.
|
|
3
|
+
*
|
|
4
|
+
* With cross-origin cookie deployments (sameSite=none), browsers attach auth
|
|
5
|
+
* cookies to cross-site requests. CORS only protects *reading* responses — it
|
|
6
|
+
* does not stop the side effects of a forged POST/PUT/PATCH/DELETE. This check
|
|
7
|
+
* rejects browser-originated state-changing requests whose Origin header is
|
|
8
|
+
* neither the API itself (same-origin) nor a configured CORS origin.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately conservative:
|
|
11
|
+
* - No Origin header → ALLOWED. Non-browser clients (curl, Postman,
|
|
12
|
+
* server-to-server) omit it, and they carry no ambient cookies, so they are
|
|
13
|
+
* not CSRF vectors. Browsers always send Origin on cross-site state-changing
|
|
14
|
+
* requests, which is the only case this defends against.
|
|
15
|
+
* - allowAllOrigins (development CORS) → ALLOWED.
|
|
16
|
+
*/
|
|
17
|
+
export function isOriginAllowed(
|
|
18
|
+
origin: string | undefined,
|
|
19
|
+
requestHost: string | undefined,
|
|
20
|
+
allowedOrigins: ReadonlySet<string>,
|
|
21
|
+
allowAllOrigins: boolean,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (!origin) return true; // non-browser client — not a CSRF vector
|
|
24
|
+
if (allowAllOrigins) return true; // development: CORS allows all origins
|
|
25
|
+
if (allowedOrigins.has(origin)) return true; // configured cross-origin client
|
|
26
|
+
|
|
27
|
+
// Same-origin request: the Origin's host matches the request's Host header.
|
|
28
|
+
// Compared scheme-insensitively — TLS usually terminates at the reverse
|
|
29
|
+
// proxy, so the API may see the request as http while Origin says https.
|
|
30
|
+
// The Host header is lowercased before comparing: `new URL()` normalizes the
|
|
31
|
+
// Origin's host to lowercase, but the raw Host header arrives as-sent and
|
|
32
|
+
// host names are case-insensitive (RFC 9110).
|
|
33
|
+
try {
|
|
34
|
+
return requestHost !== undefined && new URL(origin).host === requestHost.toLowerCase();
|
|
35
|
+
} catch {
|
|
36
|
+
return false; // malformed Origin header — reject
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -10,7 +10,7 @@ export function getRedis(): Redis {
|
|
|
10
10
|
maxRetriesPerRequest: env.REDIS_MAX_RETRIES,
|
|
11
11
|
connectTimeout: env.REDIS_CONNECT_TIMEOUT,
|
|
12
12
|
lazyConnect: true,
|
|
13
|
-
retryStrategy: (times: number) => {
|
|
13
|
+
retryStrategy: (times: number): number | null => {
|
|
14
14
|
// Exponential backoff with max delay of 3 seconds
|
|
15
15
|
if (times > env.REDIS_MAX_RETRIES) {
|
|
16
16
|
// Stop retrying after max retries
|
|
@@ -3,8 +3,8 @@ import * as authService from '../auth.service.js';
|
|
|
3
3
|
import * as authRepo from '../auth.repo.js';
|
|
4
4
|
import * as authLib from '@libs/auth.js';
|
|
5
5
|
import { hashPassword, verifyPassword } from '@libs/password.js';
|
|
6
|
-
import { ConflictError, UnauthorizedError, NotFoundError } from '@shared/errors/errors.js';
|
|
7
|
-
import { testUsers, testRefreshToken, resetMocks } from '@/test/setup.js';
|
|
6
|
+
import { ConflictError, UnauthorizedError, ForbiddenError, NotFoundError } from '@shared/errors/errors.js';
|
|
7
|
+
import { testUsers, testRefreshToken, testSession, resetMocks } from '@/test/setup.js';
|
|
8
8
|
import { sessionRepository } from '../session.repo.js';
|
|
9
9
|
|
|
10
10
|
// Mock dependencies
|
|
@@ -13,10 +13,31 @@ vi.mock('@libs/auth.js');
|
|
|
13
13
|
vi.mock('@libs/password.js');
|
|
14
14
|
vi.mock('../session.repo.js');
|
|
15
15
|
|
|
16
|
+
// In-memory Redis stub — account lockout (email+IP) uses Redis. Tests must
|
|
17
|
+
// never require a live Redis instance.
|
|
18
|
+
const mockRedis = vi.hoisted(() => ({
|
|
19
|
+
exists: vi.fn(),
|
|
20
|
+
incr: vi.fn(),
|
|
21
|
+
expire: vi.fn(),
|
|
22
|
+
set: vi.fn(),
|
|
23
|
+
get: vi.fn(),
|
|
24
|
+
del: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('@libs/redis.js', () => ({
|
|
28
|
+
getRedis: (): typeof mockRedis => mockRedis,
|
|
29
|
+
}));
|
|
30
|
+
|
|
16
31
|
describe('Auth Service', () => {
|
|
17
32
|
beforeEach(() => {
|
|
18
33
|
resetMocks();
|
|
19
34
|
vi.clearAllMocks();
|
|
35
|
+
// Default Redis behavior: nothing locked, first failure
|
|
36
|
+
mockRedis.exists.mockResolvedValue(0);
|
|
37
|
+
mockRedis.incr.mockResolvedValue(1);
|
|
38
|
+
mockRedis.expire.mockResolvedValue(1);
|
|
39
|
+
mockRedis.set.mockResolvedValue('OK');
|
|
40
|
+
mockRedis.del.mockResolvedValue(1);
|
|
20
41
|
});
|
|
21
42
|
|
|
22
43
|
describe('register', () => {
|
|
@@ -28,7 +49,8 @@ describe('Auth Service', () => {
|
|
|
28
49
|
};
|
|
29
50
|
|
|
30
51
|
it('should successfully register a new user', async () => {
|
|
31
|
-
// Arrange
|
|
52
|
+
// Arrange — REQUIRE_USER_VERIFICATION=false in tests, so the user is
|
|
53
|
+
// active immediately and tokens are issued.
|
|
32
54
|
const hashedPassword = '$2a$12$hashedpassword';
|
|
33
55
|
const createdUser = {
|
|
34
56
|
...testUsers.validUser,
|
|
@@ -40,11 +62,13 @@ describe('Auth Service', () => {
|
|
|
40
62
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
41
63
|
|
|
42
64
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
65
|
+
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
|
|
43
66
|
vi.mocked(hashPassword).mockResolvedValue(hashedPassword);
|
|
44
67
|
vi.mocked(authRepo.createUser).mockResolvedValue(createdUser);
|
|
45
68
|
vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
|
|
46
69
|
vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
|
|
47
70
|
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
71
|
+
vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
|
|
48
72
|
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
49
73
|
|
|
50
74
|
// Act
|
|
@@ -58,15 +82,23 @@ describe('Auth Service', () => {
|
|
|
58
82
|
password: hashedPassword,
|
|
59
83
|
firstName: validRegisterInput.firstName,
|
|
60
84
|
lastName: validRegisterInput.lastName,
|
|
85
|
+
isActive: true,
|
|
61
86
|
});
|
|
62
87
|
expect(authLib.signAccessToken).toHaveBeenCalledWith({
|
|
63
88
|
userId: createdUser.id,
|
|
64
89
|
role: createdUser.role,
|
|
65
90
|
});
|
|
66
91
|
expect(authLib.generateRefreshToken).toHaveBeenCalled();
|
|
92
|
+
expect(sessionRepository.createSession).toHaveBeenCalledWith({
|
|
93
|
+
userId: createdUser.id,
|
|
94
|
+
deviceInfo: undefined,
|
|
95
|
+
ipAddress: undefined,
|
|
96
|
+
expiresAt,
|
|
97
|
+
});
|
|
67
98
|
expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
|
|
68
99
|
token: refreshToken,
|
|
69
100
|
userId: createdUser.id,
|
|
101
|
+
sessionId: testSession.id,
|
|
70
102
|
expiresAt,
|
|
71
103
|
});
|
|
72
104
|
expect(result).toEqual({
|
|
@@ -79,6 +111,7 @@ describe('Auth Service', () => {
|
|
|
79
111
|
}),
|
|
80
112
|
accessToken,
|
|
81
113
|
refreshToken,
|
|
114
|
+
requiresVerification: false,
|
|
82
115
|
});
|
|
83
116
|
expect(result.user).not.toHaveProperty('password');
|
|
84
117
|
});
|
|
@@ -93,6 +126,19 @@ describe('Auth Service', () => {
|
|
|
93
126
|
expect(hashPassword).not.toHaveBeenCalled();
|
|
94
127
|
expect(authRepo.createUser).not.toHaveBeenCalled();
|
|
95
128
|
});
|
|
129
|
+
|
|
130
|
+
it('should throw ConflictError if a soft-deleted account exists with this email', async () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
133
|
+
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue({
|
|
134
|
+
...testUsers.validUser,
|
|
135
|
+
deletedAt: new Date(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Act & Assert
|
|
139
|
+
await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
|
|
140
|
+
expect(authRepo.createUser).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
96
142
|
});
|
|
97
143
|
|
|
98
144
|
describe('login', () => {
|
|
@@ -112,6 +158,7 @@ describe('Auth Service', () => {
|
|
|
112
158
|
vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
|
|
113
159
|
vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
|
|
114
160
|
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
161
|
+
vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
|
|
115
162
|
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
116
163
|
|
|
117
164
|
// Act
|
|
@@ -120,6 +167,18 @@ describe('Auth Service', () => {
|
|
|
120
167
|
// Assert
|
|
121
168
|
expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validLoginInput.email);
|
|
122
169
|
expect(verifyPassword).toHaveBeenCalledWith(validLoginInput.password, testUsers.validUser.password);
|
|
170
|
+
expect(sessionRepository.createSession).toHaveBeenCalledWith({
|
|
171
|
+
userId: testUsers.validUser.id,
|
|
172
|
+
deviceInfo: undefined,
|
|
173
|
+
ipAddress: undefined,
|
|
174
|
+
expiresAt,
|
|
175
|
+
});
|
|
176
|
+
expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
|
|
177
|
+
token: refreshToken,
|
|
178
|
+
userId: testUsers.validUser.id,
|
|
179
|
+
sessionId: testSession.id,
|
|
180
|
+
expiresAt,
|
|
181
|
+
});
|
|
123
182
|
expect(result).toEqual({
|
|
124
183
|
user: expect.objectContaining({
|
|
125
184
|
id: testUsers.validUser.id,
|
|
@@ -134,6 +193,7 @@ describe('Auth Service', () => {
|
|
|
134
193
|
it('should throw UnauthorizedError if user not found', async () => {
|
|
135
194
|
// Arrange
|
|
136
195
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
|
|
196
|
+
vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
|
|
137
197
|
|
|
138
198
|
// Act & Assert
|
|
139
199
|
await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
|
|
@@ -141,13 +201,15 @@ describe('Auth Service', () => {
|
|
|
141
201
|
expect(verifyPassword).not.toHaveBeenCalled();
|
|
142
202
|
});
|
|
143
203
|
|
|
144
|
-
it('should throw
|
|
204
|
+
it('should throw ForbiddenError if account is not activated', async () => {
|
|
145
205
|
// Arrange
|
|
146
206
|
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.inactiveUser);
|
|
147
207
|
|
|
148
208
|
// Act & Assert
|
|
149
|
-
await expect(authService.login(validLoginInput)).rejects.toThrow(
|
|
150
|
-
await expect(authService.login(validLoginInput)).rejects.toThrow(
|
|
209
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(ForbiddenError);
|
|
210
|
+
await expect(authService.login(validLoginInput)).rejects.toThrow(
|
|
211
|
+
'Account is not activated. Please verify your account.',
|
|
212
|
+
);
|
|
151
213
|
expect(verifyPassword).not.toHaveBeenCalled();
|
|
152
214
|
});
|
|
153
215
|
|
|
@@ -162,6 +224,147 @@ describe('Auth Service', () => {
|
|
|
162
224
|
});
|
|
163
225
|
});
|
|
164
226
|
|
|
227
|
+
describe('login — account lockout (email+IP scoped)', () => {
|
|
228
|
+
const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
|
|
229
|
+
const attackerIp = '203.0.113.7';
|
|
230
|
+
|
|
231
|
+
it('should reject login when the email+IP pair is locked, without verifying the password', async () => {
|
|
232
|
+
// Arrange
|
|
233
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
234
|
+
mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
|
|
235
|
+
|
|
236
|
+
// Act & Assert
|
|
237
|
+
await expect(
|
|
238
|
+
authService.login(loginInput, 'test-agent', attackerIp),
|
|
239
|
+
).rejects.toThrow('Invalid email or password');
|
|
240
|
+
expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
|
|
241
|
+
expect(verifyPassword).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should NOT lock out the same email from a different IP (no remote lockout DoS)', async () => {
|
|
245
|
+
// Arrange — lock exists only for the attacker's IP; victim logs in from
|
|
246
|
+
// their own IP and Redis reports no lock for that pair.
|
|
247
|
+
const victimIp = '198.51.100.42';
|
|
248
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
249
|
+
|
|
250
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
251
|
+
mockRedis.exists.mockResolvedValue(0);
|
|
252
|
+
vi.mocked(verifyPassword).mockResolvedValue(true);
|
|
253
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue('access');
|
|
254
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
|
|
255
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
256
|
+
vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
|
|
257
|
+
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
258
|
+
|
|
259
|
+
// Act
|
|
260
|
+
const result = await authService.login(
|
|
261
|
+
{ ...loginInput, password: 'Password123!' },
|
|
262
|
+
'test-agent',
|
|
263
|
+
victimIp,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Assert — lock was checked for the VICTIM's pair only, and login succeeded
|
|
267
|
+
expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${victimIp}`);
|
|
268
|
+
expect(result.accessToken).toBe('access');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should record a failed attempt against the email+IP pair on bad password', async () => {
|
|
272
|
+
// Arrange
|
|
273
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
274
|
+
vi.mocked(verifyPassword).mockResolvedValue(false);
|
|
275
|
+
mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
|
|
276
|
+
|
|
277
|
+
// Act & Assert
|
|
278
|
+
await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
|
|
279
|
+
UnauthorizedError,
|
|
280
|
+
);
|
|
281
|
+
expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
|
|
282
|
+
expect(mockRedis.set).not.toHaveBeenCalled(); // no lock yet
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should set a lock for the email+IP pair after 5 failures (15 min)', async () => {
|
|
286
|
+
// Arrange
|
|
287
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
288
|
+
vi.mocked(verifyPassword).mockResolvedValue(false);
|
|
289
|
+
mockRedis.incr.mockResolvedValue(5); // hits the first threshold
|
|
290
|
+
|
|
291
|
+
// Act & Assert
|
|
292
|
+
await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
|
|
293
|
+
UnauthorizedError,
|
|
294
|
+
);
|
|
295
|
+
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
296
|
+
`login-lock:test@example.com:${attackerIp}`,
|
|
297
|
+
'1',
|
|
298
|
+
'EX',
|
|
299
|
+
15 * 60,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should fail open and allow login when Redis is unavailable', async () => {
|
|
304
|
+
// Arrange — Redis down: lockout checks must not block all logins
|
|
305
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
306
|
+
vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
|
|
307
|
+
mockRedis.exists.mockRejectedValue(new Error('Redis down'));
|
|
308
|
+
mockRedis.del.mockRejectedValue(new Error('Redis down'));
|
|
309
|
+
vi.mocked(verifyPassword).mockResolvedValue(true);
|
|
310
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue('access');
|
|
311
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
|
|
312
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
|
|
313
|
+
vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
|
|
314
|
+
vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
|
|
315
|
+
|
|
316
|
+
// Act
|
|
317
|
+
const result = await authService.login(
|
|
318
|
+
{ ...loginInput, password: 'Password123!' },
|
|
319
|
+
'test-agent',
|
|
320
|
+
attackerIp,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Assert
|
|
324
|
+
expect(result.accessToken).toBe('access');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
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
|
+
|
|
165
368
|
describe('refresh', () => {
|
|
166
369
|
const validRefreshToken = 'valid-refresh-token';
|
|
167
370
|
|
|
@@ -196,6 +399,7 @@ describe('Auth Service', () => {
|
|
|
196
399
|
expect(authRepo.rotateRefreshToken).toHaveBeenCalledWith(validRefreshToken, {
|
|
197
400
|
token: newRefreshToken,
|
|
198
401
|
userId: testUsers.validUser.id,
|
|
402
|
+
sessionId: undefined, // fixture sessionId is null → normalized to undefined
|
|
199
403
|
expiresAt,
|
|
200
404
|
});
|
|
201
405
|
expect(result).toEqual({
|
|
@@ -242,7 +446,7 @@ describe('Auth Service', () => {
|
|
|
242
446
|
await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
|
|
243
447
|
});
|
|
244
448
|
|
|
245
|
-
it('should throw
|
|
449
|
+
it('should throw ForbiddenError if user is not activated', async () => {
|
|
246
450
|
// Arrange
|
|
247
451
|
const storedToken = {
|
|
248
452
|
...testRefreshToken,
|
|
@@ -251,35 +455,73 @@ describe('Auth Service', () => {
|
|
|
251
455
|
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
252
456
|
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.inactiveUser);
|
|
253
457
|
|
|
458
|
+
// Act & Assert
|
|
459
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(ForbiddenError);
|
|
460
|
+
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(
|
|
461
|
+
'Account is not activated. Please verify your account.',
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should revoke ALL refresh tokens and sessions when token reuse is detected', async () => {
|
|
466
|
+
// Regression test for refresh-token-reuse family revocation:
|
|
467
|
+
// the token was found, but the atomic rotation reports it was already
|
|
468
|
+
// consumed by a concurrent request — two parties presented the same
|
|
469
|
+
// single-use token. The whole family must be revoked, because whichever
|
|
470
|
+
// party redeemed it first (possibly an attacker) holds a valid token.
|
|
471
|
+
const storedToken = {
|
|
472
|
+
...testRefreshToken,
|
|
473
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
474
|
+
};
|
|
475
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
476
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
|
|
477
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue('access');
|
|
478
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
|
|
479
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
|
|
480
|
+
vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(false); // already consumed
|
|
481
|
+
vi.mocked(authRepo.deleteRefreshTokensByUserId).mockResolvedValue(undefined);
|
|
482
|
+
vi.mocked(sessionRepository.deleteAllUserSessions).mockResolvedValue(1);
|
|
483
|
+
|
|
254
484
|
// Act & Assert
|
|
255
485
|
await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
|
|
256
|
-
|
|
486
|
+
expect(authRepo.deleteRefreshTokensByUserId).toHaveBeenCalledWith(testUsers.validUser.id);
|
|
487
|
+
expect(sessionRepository.deleteAllUserSessions).toHaveBeenCalledWith(testUsers.validUser.id);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should NOT revoke the token family on a successful rotation', async () => {
|
|
491
|
+
// Companion to the reuse test — the revocation path must not fire on
|
|
492
|
+
// the happy path.
|
|
493
|
+
const storedToken = {
|
|
494
|
+
...testRefreshToken,
|
|
495
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
496
|
+
};
|
|
497
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
498
|
+
vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
|
|
499
|
+
vi.mocked(authLib.signAccessToken).mockReturnValue('access');
|
|
500
|
+
vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
|
|
501
|
+
vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
|
|
502
|
+
vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(true);
|
|
503
|
+
|
|
504
|
+
// Act
|
|
505
|
+
await authService.refresh(validRefreshToken);
|
|
506
|
+
|
|
507
|
+
// Assert
|
|
508
|
+
expect(authRepo.deleteRefreshTokensByUserId).not.toHaveBeenCalled();
|
|
509
|
+
expect(sessionRepository.deleteAllUserSessions).not.toHaveBeenCalled();
|
|
257
510
|
});
|
|
258
511
|
});
|
|
259
512
|
|
|
260
513
|
describe('logout', () => {
|
|
261
|
-
it('should delete refresh token and
|
|
514
|
+
it('should delete refresh token and its linked session', async () => {
|
|
262
515
|
// Arrange
|
|
263
516
|
const refreshToken = 'valid-refresh-token';
|
|
264
|
-
const tokenCreatedAt = new Date('2024-06-01T12:00:00Z');
|
|
265
517
|
const storedToken = {
|
|
266
518
|
...testRefreshToken,
|
|
267
519
|
token: refreshToken,
|
|
268
|
-
|
|
269
|
-
};
|
|
270
|
-
const matchingSession = {
|
|
271
|
-
id: 'session-1',
|
|
272
|
-
userId: testUsers.validUser.id,
|
|
273
|
-
deviceInfo: 'test-agent',
|
|
274
|
-
ipAddress: '127.0.0.1',
|
|
275
|
-
lastActiveAt: new Date(),
|
|
276
|
-
expiresAt: new Date(Date.now() + 86400000),
|
|
277
|
-
createdAt: new Date('2024-06-01T12:00:00.100Z'), // within 5s of token
|
|
520
|
+
sessionId: 'session-1',
|
|
278
521
|
};
|
|
279
522
|
|
|
280
523
|
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
281
524
|
vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
|
282
|
-
vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([matchingSession]);
|
|
283
525
|
vi.mocked(sessionRepository.deleteSession).mockResolvedValue(undefined as never);
|
|
284
526
|
|
|
285
527
|
// Act
|
|
@@ -288,46 +530,34 @@ describe('Auth Service', () => {
|
|
|
288
530
|
// Assert
|
|
289
531
|
expect(authRepo.findRefreshToken).toHaveBeenCalledWith(refreshToken);
|
|
290
532
|
expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
|
|
291
|
-
expect(sessionRepository.
|
|
292
|
-
expect(sessionRepository.deleteSession).toHaveBeenCalledWith(matchingSession.id);
|
|
533
|
+
expect(sessionRepository.deleteSession).toHaveBeenCalledWith('session-1');
|
|
293
534
|
});
|
|
294
535
|
|
|
295
|
-
it('should not delete session
|
|
296
|
-
// Arrange
|
|
536
|
+
it('should not delete a session when the token has no sessionId', async () => {
|
|
537
|
+
// Arrange — testRefreshToken fixture has sessionId: null
|
|
297
538
|
const refreshToken = 'valid-refresh-token';
|
|
298
|
-
|
|
539
|
+
vi.mocked(authRepo.findRefreshToken).mockResolvedValue({
|
|
299
540
|
...testRefreshToken,
|
|
300
541
|
token: refreshToken,
|
|
301
|
-
|
|
302
|
-
};
|
|
303
|
-
const unmatchedSession = {
|
|
304
|
-
id: 'session-2',
|
|
305
|
-
userId: testUsers.validUser.id,
|
|
306
|
-
deviceInfo: null,
|
|
307
|
-
ipAddress: null,
|
|
308
|
-
lastActiveAt: new Date(),
|
|
309
|
-
expiresAt: new Date(Date.now() + 86400000),
|
|
310
|
-
createdAt: new Date('2024-05-01T00:00:00Z'), // way off — no match
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
|
|
542
|
+
});
|
|
314
543
|
vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
|
|
315
|
-
vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([unmatchedSession]);
|
|
316
544
|
|
|
317
545
|
// Act
|
|
318
546
|
await authService.logout(refreshToken);
|
|
319
547
|
|
|
320
548
|
// Assert
|
|
549
|
+
expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
|
|
321
550
|
expect(sessionRepository.deleteSession).not.toHaveBeenCalled();
|
|
322
551
|
});
|
|
323
552
|
|
|
324
|
-
it('should
|
|
325
|
-
//
|
|
326
|
-
|
|
553
|
+
it('should propagate unexpected repository errors', async () => {
|
|
554
|
+
// "Token already deleted" is handled gracefully inside the repo (P2025);
|
|
555
|
+
// anything else is a real failure and must reach the global error handler.
|
|
556
|
+
const refreshToken = 'valid-refresh-token';
|
|
327
557
|
vi.mocked(authRepo.findRefreshToken).mockRejectedValue(new Error('DB error'));
|
|
328
558
|
|
|
329
559
|
// Act & Assert
|
|
330
|
-
await expect(authService.logout(refreshToken)).
|
|
560
|
+
await expect(authService.logout(refreshToken)).rejects.toThrow('DB error');
|
|
331
561
|
});
|
|
332
562
|
});
|
|
333
563
|
|