@prsm/auth 1.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 +226 -0
- package/index.d.ts +19 -0
- package/package.json +76 -0
- package/src/__tests__/auth.test.js +1171 -0
- package/src/__tests__/impersonation-test-setup.js +208 -0
- package/src/__tests__/impersonation.test.js +473 -0
- package/src/__tests__/oauth-test-setup.js +136 -0
- package/src/__tests__/oauth.test.js +400 -0
- package/src/__tests__/prsm.test.js +215 -0
- package/src/__tests__/test-setup.js +385 -0
- package/src/__tests__/totp.test.js +158 -0
- package/src/__tests__/two-factor-test-setup.js +331 -0
- package/src/__tests__/two-factor.test.js +396 -0
- package/src/activity-logger.js +228 -0
- package/src/auth-context.js +120 -0
- package/src/auth-functions.js +520 -0
- package/src/auth-manager.js +1371 -0
- package/src/errors.js +173 -0
- package/src/hooks.js +41 -0
- package/src/index.js +23 -0
- package/src/invalidation.js +166 -0
- package/src/middleware.js +33 -0
- package/src/providers/azure-provider.js +114 -0
- package/src/providers/base-provider.js +152 -0
- package/src/providers/github-provider.js +86 -0
- package/src/providers/google-provider.js +76 -0
- package/src/providers/index.js +4 -0
- package/src/queries.js +543 -0
- package/src/schema.js +261 -0
- package/src/totp.js +221 -0
- package/src/two-factor/index.js +3 -0
- package/src/two-factor/otp-provider.js +128 -0
- package/src/two-factor/totp-provider.js +98 -0
- package/src/two-factor/two-factor-manager.js +676 -0
- package/src/types.js +399 -0
- package/src/user-roles.js +128 -0
- package/src/util.js +32 -0
- package/types/activity-logger.d.ts +73 -0
- package/types/auth-context.d.ts +88 -0
- package/types/auth-functions.d.ts +151 -0
- package/types/auth-manager.d.ts +365 -0
- package/types/errors.d.ts +108 -0
- package/types/hooks.d.ts +30 -0
- package/types/index.d.ts +13 -0
- package/types/invalidation.d.ts +40 -0
- package/types/middleware.d.ts +11 -0
- package/types/providers/azure-provider.d.ts +35 -0
- package/types/providers/base-provider.d.ts +52 -0
- package/types/providers/github-provider.d.ts +29 -0
- package/types/providers/google-provider.d.ts +29 -0
- package/types/providers/index.d.ts +4 -0
- package/types/queries.d.ts +287 -0
- package/types/schema.d.ts +37 -0
- package/types/totp.d.ts +72 -0
- package/types/two-factor/index.d.ts +3 -0
- package/types/two-factor/otp-provider.d.ts +57 -0
- package/types/two-factor/totp-provider.d.ts +58 -0
- package/types/two-factor/two-factor-manager.d.ts +191 -0
- package/types/types.d.ts +688 -0
- package/types/user-roles.d.ts +47 -0
- package/types/util.d.ts +3 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"
|
|
2
|
+
import request from "supertest"
|
|
3
|
+
import Otp from "../totp.js"
|
|
4
|
+
import { createTwoFactorTestApp } from "./two-factor-test-setup.js"
|
|
5
|
+
import { TwoFactorMechanism } from "../index.js"
|
|
6
|
+
|
|
7
|
+
describe("Two-Factor Authentication Integration Tests", () => {
|
|
8
|
+
let app
|
|
9
|
+
let pool
|
|
10
|
+
let cleanup
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const testApp = await createTwoFactorTestApp()
|
|
14
|
+
app = testApp.app
|
|
15
|
+
pool = testApp.pool
|
|
16
|
+
cleanup = testApp.cleanup
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await cleanup()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await pool.query("DELETE FROM mfa_test_2fa_tokens")
|
|
25
|
+
await pool.query("DELETE FROM mfa_test_2fa_methods")
|
|
26
|
+
await pool.query("DELETE FROM mfa_test_resets")
|
|
27
|
+
await pool.query("DELETE FROM mfa_test_remembers")
|
|
28
|
+
await pool.query("DELETE FROM mfa_test_confirmations")
|
|
29
|
+
await pool.query("DELETE FROM mfa_test_activity_log")
|
|
30
|
+
await pool.query("DELETE FROM mfa_test_accounts")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
async function registerAndLogin(agent, email) {
|
|
34
|
+
await agent.post("/set-user-session").send({ userId: `user-${email}` }).expect(200)
|
|
35
|
+
await agent.post("/register").send({ email, password: "password123" }).expect(200)
|
|
36
|
+
await agent.post("/login").send({ email, password: "password123" }).expect(200)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---- status queries ----
|
|
40
|
+
|
|
41
|
+
describe("2FA status queries", () => {
|
|
42
|
+
it("should report no 2FA initially", async () => {
|
|
43
|
+
const agent = request.agent(app)
|
|
44
|
+
await registerAndLogin(agent, "status@example.com")
|
|
45
|
+
|
|
46
|
+
const status = await agent.get("/2fa/is-enabled").expect(200)
|
|
47
|
+
expect(status.body.enabled).toBe(false)
|
|
48
|
+
expect(status.body.totp).toBe(false)
|
|
49
|
+
expect(status.body.email).toBe(false)
|
|
50
|
+
expect(status.body.sms).toBe(false)
|
|
51
|
+
|
|
52
|
+
const methods = await agent.get("/2fa/methods").expect(200)
|
|
53
|
+
expect(methods.body.methods).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("should report enabled methods after setup", async () => {
|
|
57
|
+
const agent = request.agent(app)
|
|
58
|
+
await registerAndLogin(agent, "enabled@example.com")
|
|
59
|
+
|
|
60
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
61
|
+
await agent.post("/2fa/setup-email").expect(200)
|
|
62
|
+
|
|
63
|
+
const status = await agent.get("/2fa/is-enabled").expect(200)
|
|
64
|
+
expect(status.body.enabled).toBe(true)
|
|
65
|
+
expect(status.body.totp).toBe(true)
|
|
66
|
+
expect(status.body.email).toBe(true)
|
|
67
|
+
expect(status.body.sms).toBe(false)
|
|
68
|
+
|
|
69
|
+
const methods = await agent.get("/2fa/methods").expect(200)
|
|
70
|
+
expect(methods.body.methods).toContain(TwoFactorMechanism.TOTP)
|
|
71
|
+
expect(methods.body.methods).toContain(TwoFactorMechanism.EMAIL)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ---- basic setup ----
|
|
76
|
+
|
|
77
|
+
describe("Basic 2FA Setup", () => {
|
|
78
|
+
it("should setup TOTP without verification", async () => {
|
|
79
|
+
const agent = request.agent(app)
|
|
80
|
+
await registerAndLogin(agent, "totp@example.com")
|
|
81
|
+
|
|
82
|
+
const response = await agent.post("/2fa/setup-totp").expect(200)
|
|
83
|
+
|
|
84
|
+
expect(response.body.secret).toBeDefined()
|
|
85
|
+
expect(response.body.qrCode).toBeDefined()
|
|
86
|
+
expect(Array.isArray(response.body.backupCodes)).toBe(true)
|
|
87
|
+
expect(response.body.backupCodes).toHaveLength(10)
|
|
88
|
+
|
|
89
|
+
const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
|
|
90
|
+
expect(db.rows).toHaveLength(1)
|
|
91
|
+
expect(db.rows[0].verified).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("should setup TOTP with verification required", async () => {
|
|
95
|
+
const agent = request.agent(app)
|
|
96
|
+
await registerAndLogin(agent, "totp-verify@example.com")
|
|
97
|
+
|
|
98
|
+
const response = await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
|
|
99
|
+
|
|
100
|
+
expect(response.body.secret).toBeDefined()
|
|
101
|
+
expect(response.body.backupCodes).toBeUndefined()
|
|
102
|
+
|
|
103
|
+
const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
|
|
104
|
+
expect(db.rows[0].verified).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("should complete TOTP setup with valid code", async () => {
|
|
108
|
+
const agent = request.agent(app)
|
|
109
|
+
await registerAndLogin(agent, "totp-complete@example.com")
|
|
110
|
+
|
|
111
|
+
const setup = await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
|
|
112
|
+
|
|
113
|
+
const validCode = Otp.generateTotp(setup.body.secret)
|
|
114
|
+
|
|
115
|
+
const complete = await agent.post("/2fa/verify-totp-setup").send({ code: validCode }).expect(200)
|
|
116
|
+
|
|
117
|
+
expect(complete.body.backupCodes).toBeDefined()
|
|
118
|
+
expect(complete.body.backupCodes).toHaveLength(10)
|
|
119
|
+
|
|
120
|
+
const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 1")
|
|
121
|
+
expect(db.rows[0].verified).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("should reject TOTP completion with invalid code", async () => {
|
|
125
|
+
const agent = request.agent(app)
|
|
126
|
+
await registerAndLogin(agent, "totp-invalid@example.com")
|
|
127
|
+
|
|
128
|
+
await agent.post("/2fa/setup-totp").send({ requireVerification: true }).expect(200)
|
|
129
|
+
|
|
130
|
+
const response = await agent.post("/2fa/verify-totp-setup").send({ code: "000000" })
|
|
131
|
+
expect(response.status).toBe(400)
|
|
132
|
+
expect(response.body.error).toContain("Invalid two-factor")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("should reject duplicate TOTP setup", async () => {
|
|
136
|
+
const agent = request.agent(app)
|
|
137
|
+
await registerAndLogin(agent, "totp-dup@example.com")
|
|
138
|
+
|
|
139
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
140
|
+
|
|
141
|
+
const response = await agent.post("/2fa/setup-totp")
|
|
142
|
+
expect(response.status).toBe(400)
|
|
143
|
+
expect(response.body.error).toContain("already enabled")
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("should setup email 2FA", async () => {
|
|
147
|
+
const agent = request.agent(app)
|
|
148
|
+
await registerAndLogin(agent, "email2fa@example.com")
|
|
149
|
+
|
|
150
|
+
await agent.post("/2fa/setup-email").expect(200)
|
|
151
|
+
|
|
152
|
+
const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 2")
|
|
153
|
+
expect(db.rows).toHaveLength(1)
|
|
154
|
+
expect(db.rows[0].verified).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("should setup SMS 2FA", async () => {
|
|
158
|
+
const agent = request.agent(app)
|
|
159
|
+
await registerAndLogin(agent, "sms@example.com")
|
|
160
|
+
|
|
161
|
+
await agent.post("/2fa/setup-sms").send({ phoneNumber: "+1234567890", requireVerification: false }).expect(200)
|
|
162
|
+
|
|
163
|
+
const db = await pool.query("SELECT * FROM mfa_test_2fa_methods WHERE mechanism = 3")
|
|
164
|
+
expect(db.rows).toHaveLength(1)
|
|
165
|
+
expect(db.rows[0].secret).toBe("+1234567890")
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ---- login flow with 2FA ----
|
|
170
|
+
|
|
171
|
+
describe("2FA Login Flow", () => {
|
|
172
|
+
it("should require 2FA during login and allow completion with email OTP", async () => {
|
|
173
|
+
const agent = request.agent(app)
|
|
174
|
+
await registerAndLogin(agent, "login2fa@example.com")
|
|
175
|
+
|
|
176
|
+
await agent.post("/2fa/setup-email").expect(200)
|
|
177
|
+
await agent.post("/logout").expect(200)
|
|
178
|
+
|
|
179
|
+
const loginResponse = await agent.post("/login").send({ email: "login2fa@example.com", password: "password123" }).expect(202)
|
|
180
|
+
|
|
181
|
+
expect(loginResponse.body.requiresTwoFactor).toBe(true)
|
|
182
|
+
expect(loginResponse.body.availableMethods.email).toBeDefined()
|
|
183
|
+
|
|
184
|
+
// not logged in yet
|
|
185
|
+
await agent.get("/profile").expect(401)
|
|
186
|
+
|
|
187
|
+
// get the OTP that was captured during login
|
|
188
|
+
const otpResponse = await agent.get("/test-otp")
|
|
189
|
+
const otpCode = otpResponse.body.otp
|
|
190
|
+
expect(otpCode).toBeDefined()
|
|
191
|
+
|
|
192
|
+
await agent.post("/verify-2fa").send({ code: otpCode }).expect(200)
|
|
193
|
+
|
|
194
|
+
const profile = await agent.get("/profile").expect(200)
|
|
195
|
+
expect(profile.body.email).toBe("login2fa@example.com")
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("should require 2FA during login and allow completion with TOTP", async () => {
|
|
199
|
+
const agent = request.agent(app)
|
|
200
|
+
await registerAndLogin(agent, "logintotp@example.com")
|
|
201
|
+
|
|
202
|
+
const setup = await agent.post("/2fa/setup-totp").expect(200)
|
|
203
|
+
const secret = setup.body.secret
|
|
204
|
+
|
|
205
|
+
await agent.post("/logout").expect(200)
|
|
206
|
+
|
|
207
|
+
const loginResponse = await agent.post("/login").send({ email: "logintotp@example.com", password: "password123" }).expect(202)
|
|
208
|
+
|
|
209
|
+
expect(loginResponse.body.requiresTwoFactor).toBe(true)
|
|
210
|
+
expect(loginResponse.body.availableMethods.totp).toBe(true)
|
|
211
|
+
|
|
212
|
+
const validCode = Otp.generateTotp(secret)
|
|
213
|
+
|
|
214
|
+
await agent.post("/verify-2fa-totp").send({ code: validCode }).expect(200)
|
|
215
|
+
|
|
216
|
+
const profile = await agent.get("/profile").expect(200)
|
|
217
|
+
expect(profile.body.email).toBe("logintotp@example.com")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("should allow login with backup code", async () => {
|
|
221
|
+
const agent = request.agent(app)
|
|
222
|
+
await registerAndLogin(agent, "loginbackup@example.com")
|
|
223
|
+
|
|
224
|
+
const setup = await agent.post("/2fa/setup-totp").expect(200)
|
|
225
|
+
const backupCode = setup.body.backupCodes[0]
|
|
226
|
+
|
|
227
|
+
await agent.post("/logout").expect(200)
|
|
228
|
+
|
|
229
|
+
await agent.post("/login").send({ email: "loginbackup@example.com", password: "password123" }).expect(202)
|
|
230
|
+
|
|
231
|
+
await agent.post("/verify-2fa-backup").send({ code: backupCode }).expect(200)
|
|
232
|
+
|
|
233
|
+
const profile = await agent.get("/profile").expect(200)
|
|
234
|
+
expect(profile.body.email).toBe("loginbackup@example.com")
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("should reject invalid backup code", async () => {
|
|
238
|
+
const agent = request.agent(app)
|
|
239
|
+
await registerAndLogin(agent, "badbackup@example.com")
|
|
240
|
+
|
|
241
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
242
|
+
await agent.post("/logout").expect(200)
|
|
243
|
+
|
|
244
|
+
await agent.post("/login").send({ email: "badbackup@example.com", password: "password123" }).expect(202)
|
|
245
|
+
|
|
246
|
+
const response = await agent.post("/verify-2fa-backup").send({ code: "INVALID1" })
|
|
247
|
+
expect(response.status).toBe(400)
|
|
248
|
+
expect(response.body.errorType).toBe("InvalidBackupCodeError")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("should consume backup code (single use)", async () => {
|
|
252
|
+
const agent = request.agent(app)
|
|
253
|
+
await registerAndLogin(agent, "consumebackup@example.com")
|
|
254
|
+
|
|
255
|
+
const setup = await agent.post("/2fa/setup-totp").expect(200)
|
|
256
|
+
const backupCode = setup.body.backupCodes[0]
|
|
257
|
+
|
|
258
|
+
await agent.post("/logout").expect(200)
|
|
259
|
+
|
|
260
|
+
// use backup code first time
|
|
261
|
+
await agent.post("/login").send({ email: "consumebackup@example.com", password: "password123" }).expect(202)
|
|
262
|
+
await agent.post("/verify-2fa-backup").send({ code: backupCode }).expect(200)
|
|
263
|
+
|
|
264
|
+
await agent.post("/logout").expect(200)
|
|
265
|
+
|
|
266
|
+
// try to use same backup code again
|
|
267
|
+
await agent.post("/login").send({ email: "consumebackup@example.com", password: "password123" }).expect(202)
|
|
268
|
+
const response = await agent.post("/verify-2fa-backup").send({ code: backupCode })
|
|
269
|
+
expect(response.status).toBe(400)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("should reject invalid TOTP code during login", async () => {
|
|
273
|
+
const agent = request.agent(app)
|
|
274
|
+
await registerAndLogin(agent, "badtotp@example.com")
|
|
275
|
+
|
|
276
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
277
|
+
await agent.post("/logout").expect(200)
|
|
278
|
+
|
|
279
|
+
await agent.post("/login").send({ email: "badtotp@example.com", password: "password123" }).expect(202)
|
|
280
|
+
|
|
281
|
+
const response = await agent.post("/verify-2fa-totp").send({ code: "000000" })
|
|
282
|
+
expect(response.status).toBe(400)
|
|
283
|
+
expect(response.body.errorType).toBe("InvalidTwoFactorCodeError")
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ---- 2FA management ----
|
|
288
|
+
|
|
289
|
+
describe("2FA Management", () => {
|
|
290
|
+
it("should disable TOTP method", async () => {
|
|
291
|
+
const agent = request.agent(app)
|
|
292
|
+
await registerAndLogin(agent, "disable@example.com")
|
|
293
|
+
|
|
294
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
295
|
+
|
|
296
|
+
let methods = await agent.get("/2fa/methods").expect(200)
|
|
297
|
+
expect(methods.body.methods).toContain(TwoFactorMechanism.TOTP)
|
|
298
|
+
|
|
299
|
+
await agent.delete("/2fa/method/1").expect(200)
|
|
300
|
+
|
|
301
|
+
methods = await agent.get("/2fa/methods").expect(200)
|
|
302
|
+
expect(methods.body.methods).not.toContain(TwoFactorMechanism.TOTP)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("should reject disabling non-existent method", async () => {
|
|
306
|
+
const agent = request.agent(app)
|
|
307
|
+
await registerAndLogin(agent, "nodisable@example.com")
|
|
308
|
+
|
|
309
|
+
const response = await agent.delete("/2fa/method/1")
|
|
310
|
+
expect(response.status).toBe(400)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("should regenerate backup codes", async () => {
|
|
314
|
+
const agent = request.agent(app)
|
|
315
|
+
await registerAndLogin(agent, "regen@example.com")
|
|
316
|
+
|
|
317
|
+
const setup = await agent.post("/2fa/setup-totp").expect(200)
|
|
318
|
+
const originalCodes = setup.body.backupCodes
|
|
319
|
+
|
|
320
|
+
const regen = await agent.post("/2fa/regenerate-backup-codes").expect(200)
|
|
321
|
+
expect(regen.body.codes).toHaveLength(10)
|
|
322
|
+
expect(regen.body.codes).not.toEqual(originalCodes)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("should reject backup code regeneration without TOTP setup", async () => {
|
|
326
|
+
const agent = request.agent(app)
|
|
327
|
+
await registerAndLogin(agent, "noregen@example.com")
|
|
328
|
+
|
|
329
|
+
const response = await agent.post("/2fa/regenerate-backup-codes")
|
|
330
|
+
expect(response.status).toBe(400)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("should get contact info for email and SMS", async () => {
|
|
334
|
+
const agent = request.agent(app)
|
|
335
|
+
await registerAndLogin(agent, "contact@example.com")
|
|
336
|
+
|
|
337
|
+
await agent.post("/2fa/setup-email").expect(200)
|
|
338
|
+
await agent.post("/2fa/setup-sms").send({ phoneNumber: "+15551234567", requireVerification: false }).expect(200)
|
|
339
|
+
|
|
340
|
+
const emailContact = await agent.get(`/2fa/contact/${TwoFactorMechanism.EMAIL}`).expect(200)
|
|
341
|
+
expect(emailContact.body.contact).toBe("contact@example.com")
|
|
342
|
+
|
|
343
|
+
const smsContact = await agent.get(`/2fa/contact/${TwoFactorMechanism.SMS}`).expect(200)
|
|
344
|
+
expect(smsContact.body.contact).toBe("+15551234567")
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it("should get TOTP URI", async () => {
|
|
348
|
+
const agent = request.agent(app)
|
|
349
|
+
await registerAndLogin(agent, "uri@example.com")
|
|
350
|
+
|
|
351
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
352
|
+
|
|
353
|
+
const uriResponse = await agent.get("/2fa/totp-uri").expect(200)
|
|
354
|
+
expect(uriResponse.body.uri).toContain("otpauth://totp/")
|
|
355
|
+
expect(uriResponse.body.uri).toContain("EasyAccess")
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it("should return null TOTP URI when not setup", async () => {
|
|
359
|
+
const agent = request.agent(app)
|
|
360
|
+
await registerAndLogin(agent, "nouri@example.com")
|
|
361
|
+
|
|
362
|
+
const uriResponse = await agent.get("/2fa/totp-uri").expect(200)
|
|
363
|
+
expect(uriResponse.body.uri).toBeNull()
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// ---- edge cases ----
|
|
368
|
+
|
|
369
|
+
describe("2FA Edge Cases", () => {
|
|
370
|
+
it("should not require 2FA when no methods are enabled", async () => {
|
|
371
|
+
const agent = request.agent(app)
|
|
372
|
+
await registerAndLogin(agent, "no2fa@example.com")
|
|
373
|
+
|
|
374
|
+
await agent.post("/logout").expect(200)
|
|
375
|
+
|
|
376
|
+
// login should succeed without 2FA prompt
|
|
377
|
+
const response = await agent.post("/login").send({ email: "no2fa@example.com", password: "password123" })
|
|
378
|
+
expect(response.status).toBe(200)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("should handle multiple 2FA methods available", async () => {
|
|
382
|
+
const agent = request.agent(app)
|
|
383
|
+
await registerAndLogin(agent, "multi@example.com")
|
|
384
|
+
|
|
385
|
+
await agent.post("/2fa/setup-totp").expect(200)
|
|
386
|
+
await agent.post("/2fa/setup-email").expect(200)
|
|
387
|
+
|
|
388
|
+
await agent.post("/logout").expect(200)
|
|
389
|
+
|
|
390
|
+
const loginResponse = await agent.post("/login").send({ email: "multi@example.com", password: "password123" }).expect(202)
|
|
391
|
+
|
|
392
|
+
expect(loginResponse.body.availableMethods.totp).toBe(true)
|
|
393
|
+
expect(loginResponse.body.availableMethods.email).toBeDefined()
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import Bowser from "bowser"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import("./types.js").AuthConfig} AuthConfig
|
|
5
|
+
* @typedef {import("./types.js").AuthActivity} AuthActivity
|
|
6
|
+
* @typedef {import("./types.js").AuthActivityActionType} AuthActivityActionType
|
|
7
|
+
* @typedef {import("express").Request} Request
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class ActivityLogger {
|
|
11
|
+
/**
|
|
12
|
+
* @param {AuthConfig} config
|
|
13
|
+
*/
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config
|
|
16
|
+
this.enabled = config.activityLog?.enabled !== false // default true
|
|
17
|
+
this.maxEntries = config.activityLog?.maxEntries || 10000
|
|
18
|
+
this.allowedActions = config.activityLog?.actions || null // null means all actions
|
|
19
|
+
this.tablePrefix = config.tablePrefix || "user_"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get activityTable() {
|
|
23
|
+
return `${this.tablePrefix}activity_log`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string | null} userAgent
|
|
28
|
+
* @returns {{ browser: string | null, os: string | null, device: string | null }}
|
|
29
|
+
*/
|
|
30
|
+
parseUserAgent(userAgent) {
|
|
31
|
+
if (!userAgent) {
|
|
32
|
+
return { browser: null, os: null, device: null }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const browser = Bowser.getParser(userAgent)
|
|
37
|
+
const result = browser.getResult()
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
browser: result.browser.name || null,
|
|
41
|
+
os: result.os.name || null,
|
|
42
|
+
device: result.platform.type || "desktop",
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// fallback to simple parsing if bowser fails
|
|
46
|
+
return this.parseUserAgentSimple(userAgent)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} userAgent
|
|
52
|
+
* @returns {{ browser: string | null, os: string | null, device: string | null }}
|
|
53
|
+
*/
|
|
54
|
+
parseUserAgentSimple(userAgent) {
|
|
55
|
+
let browser = null
|
|
56
|
+
if (userAgent.includes("Chrome")) browser = "Chrome"
|
|
57
|
+
else if (userAgent.includes("Firefox")) browser = "Firefox"
|
|
58
|
+
else if (userAgent.includes("Safari")) browser = "Safari"
|
|
59
|
+
else if (userAgent.includes("Edge")) browser = "Edge"
|
|
60
|
+
|
|
61
|
+
let os = null
|
|
62
|
+
if (userAgent.includes("Windows")) os = "Windows"
|
|
63
|
+
else if (userAgent.includes("Mac OS")) os = "macOS"
|
|
64
|
+
else if (userAgent.includes("Linux")) os = "Linux"
|
|
65
|
+
else if (userAgent.includes("Android")) os = "Android"
|
|
66
|
+
else if (userAgent.includes("iOS")) os = "iOS"
|
|
67
|
+
|
|
68
|
+
let device = "desktop"
|
|
69
|
+
if (userAgent.includes("Mobile")) device = "mobile"
|
|
70
|
+
else if (userAgent.includes("Tablet")) device = "tablet"
|
|
71
|
+
|
|
72
|
+
return { browser, os, device }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Request} req
|
|
77
|
+
* @returns {string | null}
|
|
78
|
+
*/
|
|
79
|
+
getIpAddress(req) {
|
|
80
|
+
return req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || req.connection?.socket?.remoteAddress || null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {number | null} accountId
|
|
85
|
+
* @param {AuthActivityActionType} action
|
|
86
|
+
* @param {Request} req
|
|
87
|
+
* @param {boolean} [success]
|
|
88
|
+
* @param {Record<string, any>} [metadata]
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async logActivity(accountId, action, req, success = true, metadata = {}) {
|
|
92
|
+
if (!this.enabled) return
|
|
93
|
+
|
|
94
|
+
// check if this action is allowed
|
|
95
|
+
if (this.allowedActions && !this.allowedActions.includes(action)) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const userAgent = (typeof req.get === "function" ? req.get("User-Agent") : req.headers?.["user-agent"]) || null
|
|
100
|
+
const ip = this.getIpAddress(req)
|
|
101
|
+
const parsed = this.parseUserAgent(userAgent)
|
|
102
|
+
|
|
103
|
+
// auto-pick up actor when an impersonation session is active so every existing
|
|
104
|
+
// log call site is impersonation-aware without changes
|
|
105
|
+
const actorAccountId = req.session?.auth?.actor?.accountId ?? null
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// insert new activity log entry
|
|
109
|
+
await this.config.db.query(
|
|
110
|
+
`
|
|
111
|
+
INSERT INTO ${this.activityTable}
|
|
112
|
+
(account_id, actor_account_id, action, ip_address, user_agent, browser, os, device, success, metadata)
|
|
113
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
114
|
+
`,
|
|
115
|
+
[accountId, actorAccountId, action, ip, userAgent, parsed.browser, parsed.os, parsed.device, success, Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// occasionally cleanup old entries (1% chance per insert to avoid performance impact)
|
|
119
|
+
if (Math.random() < 0.01) {
|
|
120
|
+
await this.cleanup()
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// don't throw on logging errors - just log to console
|
|
124
|
+
console.error("ActivityLogger: Failed to log activity:", error)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
async cleanup() {
|
|
132
|
+
if (!this.enabled) return
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// delete entries beyond maxEntries limit
|
|
136
|
+
await this.config.db.query(
|
|
137
|
+
`
|
|
138
|
+
DELETE FROM ${this.activityTable}
|
|
139
|
+
WHERE id NOT IN (
|
|
140
|
+
SELECT id FROM ${this.activityTable}
|
|
141
|
+
ORDER BY created_at DESC
|
|
142
|
+
LIMIT $1
|
|
143
|
+
)
|
|
144
|
+
`,
|
|
145
|
+
[this.maxEntries],
|
|
146
|
+
)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error("ActivityLogger: Failed to cleanup old entries:", error)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {number} [limit]
|
|
154
|
+
* @param {number} [accountId]
|
|
155
|
+
* @returns {Promise<AuthActivity[]>}
|
|
156
|
+
*/
|
|
157
|
+
async getRecentActivity(limit = 100, accountId) {
|
|
158
|
+
if (!this.enabled) return []
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
let sql = `
|
|
162
|
+
SELECT
|
|
163
|
+
al.*,
|
|
164
|
+
a.email
|
|
165
|
+
FROM ${this.activityTable} al
|
|
166
|
+
LEFT JOIN ${this.tablePrefix}accounts a ON al.account_id = a.id
|
|
167
|
+
`
|
|
168
|
+
const params = []
|
|
169
|
+
|
|
170
|
+
if (accountId !== undefined) {
|
|
171
|
+
sql += " WHERE al.account_id = $1"
|
|
172
|
+
params.push(accountId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sql += ` ORDER BY al.created_at DESC LIMIT $${params.length + 1}`
|
|
176
|
+
params.push(Math.min(limit, 1000)) // cap at 1000 entries max
|
|
177
|
+
|
|
178
|
+
const result = await this.config.db.query(sql, params)
|
|
179
|
+
return result.rows.map((row) => ({
|
|
180
|
+
...row,
|
|
181
|
+
// metadata is a JSONB column, so node-pg already returns it parsed.
|
|
182
|
+
// only parse when a driver hands it back as a raw string
|
|
183
|
+
metadata: typeof row.metadata === "string" ? JSON.parse(row.metadata) : (row.metadata ?? null),
|
|
184
|
+
}))
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error("ActivityLogger: Failed to get recent activity:", error)
|
|
187
|
+
return []
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @returns {Promise<{ totalEntries: number, uniqueUsers: number, recentLogins: number, failedAttempts: number }>}
|
|
193
|
+
*/
|
|
194
|
+
async getActivityStats() {
|
|
195
|
+
if (!this.enabled) {
|
|
196
|
+
return {
|
|
197
|
+
totalEntries: 0,
|
|
198
|
+
uniqueUsers: 0,
|
|
199
|
+
recentLogins: 0,
|
|
200
|
+
failedAttempts: 0,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const [total, unique, recent, failed] = await Promise.all([
|
|
206
|
+
this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable}`),
|
|
207
|
+
this.config.db.query(`SELECT COUNT(DISTINCT account_id) as count FROM ${this.activityTable} WHERE account_id IS NOT NULL`),
|
|
208
|
+
this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable} WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`),
|
|
209
|
+
this.config.db.query(`SELECT COUNT(*) as count FROM ${this.activityTable} WHERE success = false AND created_at > NOW() - INTERVAL '24 hours'`),
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
totalEntries: parseInt(total.rows[0]?.count || "0"),
|
|
214
|
+
uniqueUsers: parseInt(unique.rows[0]?.count || "0"),
|
|
215
|
+
recentLogins: parseInt(recent.rows[0]?.count || "0"),
|
|
216
|
+
failedAttempts: parseInt(failed.rows[0]?.count || "0"),
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error("ActivityLogger: Failed to get activity stats:", error)
|
|
220
|
+
return {
|
|
221
|
+
totalEntries: 0,
|
|
222
|
+
uniqueUsers: 0,
|
|
223
|
+
recentLogins: 0,
|
|
224
|
+
failedAttempts: 0,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|