create-tigra 2.8.0 → 3.0.2
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 +248 -194
- package/template/server/.env.example.production +221 -168
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +32 -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 +316 -271
- package/template/server/src/config/env.ts +150 -99
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/client-ip.ts +77 -0
- 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 +220 -206
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- 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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL/IP safety helpers shared across outbound-HTTP code paths.
|
|
3
|
+
*
|
|
4
|
+
* - `redactUrl` — strips secrets (Telegram bot tokens, query strings)
|
|
5
|
+
* from a URL before it reaches any log sink.
|
|
6
|
+
* - `isPrivateOrReservedIp`— SSRF building block: returns true for any loopback /
|
|
7
|
+
* private / link-local / ULA / reserved address so the
|
|
8
|
+
* image-fetch guard can reject internal targets.
|
|
9
|
+
*
|
|
10
|
+
* Pure, dependency-free, and side-effect-free so they are trivial to unit test.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Redact a URL so it is safe to log.
|
|
15
|
+
*
|
|
16
|
+
* Telegram embeds the bot token directly in the request path:
|
|
17
|
+
* https://api.telegram.org/bot<TOKEN>/getFile
|
|
18
|
+
* https://api.telegram.org/file/bot<TOKEN>/photos/file_1.jpg
|
|
19
|
+
* Both forms (`/bot<token>/` and `/file/bot<token>/`) are rewritten to
|
|
20
|
+
* `/bot<redacted>/`. Any query string is dropped wholesale (it can also carry
|
|
21
|
+
* media tokens / signatures). Non-Telegram URLs pass through unchanged apart
|
|
22
|
+
* from query stripping. NEVER throws — an unparseable string is returned with
|
|
23
|
+
* just the same path-level redaction applied.
|
|
24
|
+
*/
|
|
25
|
+
export function redactUrl(url: string): string {
|
|
26
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Drop the query string (and fragment) — may hold signed tokens.
|
|
31
|
+
const noQuery = url.split(/[?#]/, 1)[0];
|
|
32
|
+
|
|
33
|
+
// Rewrite Telegram bot-token path segments. The token charset is [A-Za-z0-9_-]
|
|
34
|
+
// plus the ':' separating bot-id from secret, e.g. 123456:AAH...
|
|
35
|
+
return noQuery.replace(/\/bot[A-Za-z0-9:_-]+/g, '/bot<redacted>');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when `addr` is a loopback / private / link-local / ULA / unspecified /
|
|
40
|
+
* reserved IP that an SSRF target could use to reach internal infrastructure.
|
|
41
|
+
*
|
|
42
|
+
* Covers:
|
|
43
|
+
* IPv4: 0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10 (CGNAT)
|
|
44
|
+
* IPv6: ::, ::1, fc00::/7 (ULA), fe80::/10 (link-local)
|
|
45
|
+
* IPv4-mapped IPv6 (::ffff:a.b.c.d) — re-checked against the IPv4 rules.
|
|
46
|
+
*
|
|
47
|
+
* Unrecognised / unparseable input returns true (fail-closed) — the caller
|
|
48
|
+
* uses this purely to BLOCK, so an address it cannot classify is treated as
|
|
49
|
+
* unsafe rather than silently allowed.
|
|
50
|
+
*/
|
|
51
|
+
export function isPrivateOrReservedIp(addr: string): boolean {
|
|
52
|
+
if (typeof addr !== 'string' || addr.length === 0) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ip = addr.trim().toLowerCase();
|
|
57
|
+
|
|
58
|
+
// IPv4-mapped IPv6, e.g. ::ffff:10.0.0.5 or ::ffff:0a00:0005 — extract the v4 tail.
|
|
59
|
+
const mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
60
|
+
if (mapped) {
|
|
61
|
+
return isPrivateOrReservedIpv4(mapped[1]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (ip.includes(':')) {
|
|
65
|
+
return isReservedIpv6(ip);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return isPrivateOrReservedIpv4(ip);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPrivateOrReservedIpv4(ip: string): boolean {
|
|
72
|
+
const parts = ip.split('.');
|
|
73
|
+
if (parts.length !== 4) {
|
|
74
|
+
return true; // not a clean dotted-quad → block
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const octets = parts.map((p) => Number(p));
|
|
78
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
|
79
|
+
return true; // malformed → block
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [a, b] = octets;
|
|
83
|
+
|
|
84
|
+
if (a === 0) return true; // 0.0.0.0/8 (incl. unspecified)
|
|
85
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
86
|
+
if (a === 10) return true; // 10.0.0.0/8 private
|
|
87
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
|
88
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
|
89
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
90
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isReservedIpv6(ip: string): boolean {
|
|
96
|
+
// Normalise: drop zone id (fe80::1%eth0) and any brackets.
|
|
97
|
+
const bare = ip.replace(/^\[|\]$/g, '').split('%')[0];
|
|
98
|
+
|
|
99
|
+
if (bare === '::' || bare === '::0' || bare === '0:0:0:0:0:0:0:0') return true; // unspecified
|
|
100
|
+
if (bare === '::1') return true; // loopback
|
|
101
|
+
|
|
102
|
+
// First hextet determines ULA / link-local ranges.
|
|
103
|
+
const firstHextet = bare.split(':')[0];
|
|
104
|
+
if (firstHextet === '') {
|
|
105
|
+
// Address starts with '::' (e.g. '::abcd') — already handled the special
|
|
106
|
+
// cases above; remaining such addresses are not in fc00::/7 or fe80::/10.
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const value = parseInt(firstHextet, 16);
|
|
111
|
+
if (Number.isNaN(value)) {
|
|
112
|
+
return true; // unparseable hextet → block
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// fc00::/7 → first 7 bits = 1111110 → first byte 0xfc or 0xfd
|
|
116
|
+
if ((value & 0xfe00) === 0xfc00) return true;
|
|
117
|
+
// fe80::/10 → first 10 bits = 1111111010 → 0xfe80..0xfebf
|
|
118
|
+
if ((value & 0xffc0) === 0xfe80) return true;
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
@@ -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
|
|