@budibase/worker 3.23.23 → 3.23.25
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/package.json +2 -2
- package/src/api/controllers/global/auth.ts +118 -2
- package/src/api/controllers/global/configs.ts +5 -4
- package/src/api/controllers/system/tenants.ts +29 -0
- package/src/api/routes/global/auth.ts +2 -0
- package/src/api/routes/global/tests/auth.spec.ts +53 -0
- package/src/api/routes/system/tenants.ts +1 -0
- package/src/api/routes/system/tests/tenants.spec.ts +89 -0
- package/src/environment.ts +14 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/lockout.ts +36 -0
- package/src/middleware/tests/lockout.spec.ts +95 -0
- package/src/sdk/tenants/tenants.ts +29 -1
- package/src/sdk/tenants/tests/tenants.spec.ts +162 -0
- package/src/tests/api/tenants.ts +9 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@budibase/worker",
|
|
3
3
|
"email": "hi@budibase.com",
|
|
4
|
-
"version": "3.23.
|
|
4
|
+
"version": "3.23.25",
|
|
5
5
|
"description": "Budibase background service",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"repository": {
|
|
@@ -109,5 +109,5 @@
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
|
-
"gitHead": "
|
|
112
|
+
"gitHead": "aaca0ff0f2cf886148a72826d4ecd9c8b90a62b2"
|
|
113
113
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
events,
|
|
6
6
|
utils as utilsCore,
|
|
7
7
|
configs,
|
|
8
|
+
cache,
|
|
8
9
|
} from "@budibase/backend-core"
|
|
9
10
|
import {
|
|
10
11
|
ConfigType,
|
|
@@ -37,6 +38,44 @@ const { setCookie, getCookie, clearCookie } = utilsCore
|
|
|
37
38
|
|
|
38
39
|
// LOGIN / LOGOUT
|
|
39
40
|
|
|
41
|
+
const normalizeEmail = (e: string) => (e || "").toLowerCase()
|
|
42
|
+
const failKey = (email: string) => `auth:login:fail:${normalizeEmail(email)}`
|
|
43
|
+
const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
|
|
44
|
+
const isLocked = async (email: string) => {
|
|
45
|
+
return !!(await cache.get(lockKey(email)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handleLockoutResponse = (ctx: Ctx, email: string) => {
|
|
49
|
+
ctx.set("X-Account-Locked", "1")
|
|
50
|
+
ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
|
|
51
|
+
console.log(
|
|
52
|
+
`[auth] login blocked (post-failure) due to lock email=${normalizeEmail(email)}`
|
|
53
|
+
)
|
|
54
|
+
return ctx.throw(403, "Account temporarily locked. Try again later.")
|
|
55
|
+
}
|
|
56
|
+
const onFailed = async (email: string) => {
|
|
57
|
+
if (!email) return
|
|
58
|
+
const key = failKey(email)
|
|
59
|
+
const currentAttempt = Number((await cache.get(key)) || 0) || 0
|
|
60
|
+
const nextAttempt = currentAttempt + 1
|
|
61
|
+
await cache.store(key, nextAttempt, env.LOGIN_LOCKOUT_SECONDS)
|
|
62
|
+
console.log(
|
|
63
|
+
`[auth] failed login email=${normalizeEmail(email)} count=${nextAttempt}`
|
|
64
|
+
)
|
|
65
|
+
if (nextAttempt >= env.LOGIN_MAX_FAILED_ATTEMPTS) {
|
|
66
|
+
await cache.store(lockKey(email), "1", env.LOGIN_LOCKOUT_SECONDS)
|
|
67
|
+
await cache.destroy(key)
|
|
68
|
+
console.log(
|
|
69
|
+
`[auth] account locked email=${normalizeEmail(email)} for ${env.LOGIN_LOCKOUT_SECONDS}s`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const clearFailureState = async (email: string) => {
|
|
74
|
+
if (!email) return
|
|
75
|
+
await cache.destroy(failKey(email))
|
|
76
|
+
await cache.destroy(lockKey(email))
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
async function passportCallback(
|
|
41
80
|
ctx: Ctx,
|
|
42
81
|
user: User,
|
|
@@ -75,14 +114,37 @@ export const login = async (
|
|
|
75
114
|
) => {
|
|
76
115
|
const email = ctx.request.body.username
|
|
77
116
|
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
117
|
+
const dbUser = await userSdk.db.getUserByEmail(email)
|
|
118
|
+
if (dbUser && (await userSdk.db.isPreventPasswordActions(dbUser))) {
|
|
119
|
+
console.log(
|
|
120
|
+
`[auth] login prevented due to sso enforcement email=${normalizeEmail(email)}`
|
|
121
|
+
)
|
|
80
122
|
ctx.throw(403, "Invalid credentials")
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
return passport.authenticate(
|
|
84
126
|
"local",
|
|
85
127
|
async (err: any, user: User, info: any) => {
|
|
128
|
+
if (err || !user) {
|
|
129
|
+
if (dbUser) {
|
|
130
|
+
await onFailed(email)
|
|
131
|
+
}
|
|
132
|
+
if (await isLocked(email)) {
|
|
133
|
+
return handleLockoutResponse(ctx, email)
|
|
134
|
+
}
|
|
135
|
+
const reason =
|
|
136
|
+
(info && info.message) || (err && err.message) || "unknown"
|
|
137
|
+
console.log(
|
|
138
|
+
`[auth] password auth failed email=${normalizeEmail(email)} reason=${reason}`
|
|
139
|
+
)
|
|
140
|
+
// delegate to shared passport failure handling to preserve specific messages (e.g. expired)
|
|
141
|
+
return passportCallback(ctx, user as any, err, info)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await clearFailureState(email)
|
|
145
|
+
console.log(
|
|
146
|
+
`[auth] password auth success email=${normalizeEmail(user.email)}`
|
|
147
|
+
)
|
|
86
148
|
await passportCallback(ctx, user, err, info)
|
|
87
149
|
await context.identity.doInUserContext(user, ctx, async () => {
|
|
88
150
|
await events.auth.login("local", user.email)
|
|
@@ -133,6 +195,60 @@ export const reset = async (
|
|
|
133
195
|
) => {
|
|
134
196
|
const { email } = ctx.request.body
|
|
135
197
|
|
|
198
|
+
const lcEmail = (email || "").toLowerCase()
|
|
199
|
+
const ip = (ctx.ip || "").toString()
|
|
200
|
+
|
|
201
|
+
// rate limit keys
|
|
202
|
+
const emailKey = `auth:pwdreset:email:${lcEmail}`
|
|
203
|
+
const ipKey = `auth:pwdreset:ip:${ip}`
|
|
204
|
+
|
|
205
|
+
const increment = async (key: string, windowSeconds: number) => {
|
|
206
|
+
const currentAttempt = Number((await cache.get(key)) || 0) || 0
|
|
207
|
+
const nextAttempt = currentAttempt + 1
|
|
208
|
+
await cache.store(key, nextAttempt, windowSeconds)
|
|
209
|
+
return nextAttempt
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// apply per-email and per-ip rate limits
|
|
213
|
+
const nextEmail = await increment(
|
|
214
|
+
emailKey,
|
|
215
|
+
env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS
|
|
216
|
+
)
|
|
217
|
+
const nextIp = await increment(
|
|
218
|
+
ipKey,
|
|
219
|
+
env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const emailLimited = nextEmail > env.PASSWORD_RESET_RATE_EMAIL_LIMIT
|
|
223
|
+
const ipLimited = nextIp > env.PASSWORD_RESET_RATE_IP_LIMIT
|
|
224
|
+
|
|
225
|
+
if (emailLimited || ipLimited) {
|
|
226
|
+
// surfaced for ui to display
|
|
227
|
+
ctx.set(
|
|
228
|
+
"X-RateLimit-Email-Limit",
|
|
229
|
+
String(env.PASSWORD_RESET_RATE_EMAIL_LIMIT)
|
|
230
|
+
)
|
|
231
|
+
ctx.set(
|
|
232
|
+
"X-RateLimit-Email-Remaining",
|
|
233
|
+
String(Math.max(env.PASSWORD_RESET_RATE_EMAIL_LIMIT - nextEmail, 0))
|
|
234
|
+
)
|
|
235
|
+
ctx.set("X-RateLimit-IP-Limit", String(env.PASSWORD_RESET_RATE_IP_LIMIT))
|
|
236
|
+
ctx.set(
|
|
237
|
+
"X-RateLimit-IP-Remaining",
|
|
238
|
+
String(Math.max(env.PASSWORD_RESET_RATE_IP_LIMIT - nextIp, 0))
|
|
239
|
+
)
|
|
240
|
+
// best-effort retry window
|
|
241
|
+
const retryAfter = Math.max(
|
|
242
|
+
env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS,
|
|
243
|
+
env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
|
|
244
|
+
)
|
|
245
|
+
ctx.set("Retry-After", String(retryAfter))
|
|
246
|
+
console.log(
|
|
247
|
+
`[auth] password reset rate limited email=${lcEmail} ip=${ip} emailCount=${nextEmail} ipCount=${nextIp}`
|
|
248
|
+
)
|
|
249
|
+
return ctx.throw(429, "Too many password reset requests. Try again later.")
|
|
250
|
+
}
|
|
251
|
+
|
|
136
252
|
await authSdk.reset(email)
|
|
137
253
|
|
|
138
254
|
ctx.body = {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
tenancy,
|
|
11
11
|
} from "@budibase/backend-core"
|
|
12
12
|
import * as pro from "@budibase/pro"
|
|
13
|
+
import { BUILDER_URLS } from "@budibase/shared-core"
|
|
13
14
|
import {
|
|
14
15
|
AIInnerConfig,
|
|
15
16
|
Config,
|
|
@@ -676,23 +677,23 @@ export async function configChecklist(ctx: Ctx<void, ConfigChecklistResponse>) {
|
|
|
676
677
|
apps: {
|
|
677
678
|
checked: workspaces.length > 0,
|
|
678
679
|
label: "Create your first app",
|
|
679
|
-
link:
|
|
680
|
+
link: BUILDER_URLS.WORKSPACES,
|
|
680
681
|
},
|
|
681
682
|
smtp: {
|
|
682
683
|
checked: !!smtpConfig,
|
|
683
684
|
label: "Set up email",
|
|
684
|
-
link:
|
|
685
|
+
link: BUILDER_URLS.SETTINGS_EMAIL,
|
|
685
686
|
fallback: smtpConfig?.fallback || false,
|
|
686
687
|
},
|
|
687
688
|
adminUser: {
|
|
688
689
|
checked: userExists,
|
|
689
690
|
label: "Create your first user",
|
|
690
|
-
link:
|
|
691
|
+
link: BUILDER_URLS.SETTINGS_PEOPLE_USERS,
|
|
691
692
|
},
|
|
692
693
|
sso: {
|
|
693
694
|
checked: !!googleConfig || !!oidcConfig,
|
|
694
695
|
label: "Set up single sign-on",
|
|
695
|
-
link:
|
|
696
|
+
link: BUILDER_URLS.SETTINGS_AUTH,
|
|
696
697
|
},
|
|
697
698
|
branding,
|
|
698
699
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GetTenantInfoResponse, UserCtx } from "@budibase/types"
|
|
2
|
+
import { LockRequest, LockReason } from "@budibase/types"
|
|
2
3
|
import * as tenantSdk from "../../../sdk/tenants"
|
|
3
4
|
|
|
4
5
|
export async function destroy(ctx: UserCtx<void, void>) {
|
|
@@ -18,6 +19,34 @@ export async function destroy(ctx: UserCtx<void, void>) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export async function lock(ctx: UserCtx<LockRequest, void>) {
|
|
23
|
+
if (!ctx.internal) {
|
|
24
|
+
ctx.throw(403, "Only internal user can lock a tenant")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { reason } = ctx.request.body
|
|
28
|
+
if (reason !== undefined && !Object.values(LockReason).includes(reason)) {
|
|
29
|
+
ctx.throw(
|
|
30
|
+
400,
|
|
31
|
+
`Invalid lock reason. Valid values: ${Object.values(LockReason).join(", ")}`
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tenantId = ctx.params.tenantId
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (reason) {
|
|
39
|
+
await tenantSdk.lockTenant(tenantId, reason)
|
|
40
|
+
} else {
|
|
41
|
+
await tenantSdk.unlockTenant(tenantId)
|
|
42
|
+
}
|
|
43
|
+
ctx.status = 204
|
|
44
|
+
} catch (err) {
|
|
45
|
+
ctx.log.error(err)
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
export async function info(ctx: UserCtx<void, GetTenantInfoResponse>) {
|
|
22
51
|
ctx.body = await tenantSdk.tenantInfo(ctx.params.tenantId)
|
|
23
52
|
}
|
|
@@ -2,6 +2,7 @@ import * as authController from "../../controllers/global/auth"
|
|
|
2
2
|
import { auth } from "@budibase/backend-core"
|
|
3
3
|
import Joi from "joi"
|
|
4
4
|
import { loggedInRoutes } from "../endpointGroups"
|
|
5
|
+
import { lockout } from "../../../middleware"
|
|
5
6
|
|
|
6
7
|
function buildAuthValidation() {
|
|
7
8
|
// prettier-ignore
|
|
@@ -31,6 +32,7 @@ loggedInRoutes
|
|
|
31
32
|
.post(
|
|
32
33
|
"/api/global/auth/:tenantId/login",
|
|
33
34
|
buildAuthValidation(),
|
|
35
|
+
lockout,
|
|
34
36
|
authController.login
|
|
35
37
|
)
|
|
36
38
|
.post("/api/global/auth/logout", authController.logout)
|
|
@@ -140,6 +140,59 @@ describe("/api/global/auth", () => {
|
|
|
140
140
|
})
|
|
141
141
|
})
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
describe("lockout", () => {
|
|
145
|
+
it("locks the account after 5 failed attempts and unlocks after TTL", async () => {
|
|
146
|
+
const tenantId = config.tenantId!
|
|
147
|
+
const email = config.user!.email!
|
|
148
|
+
const correctPassword = config.userPassword
|
|
149
|
+
const wrongPassword = "incorrect123"
|
|
150
|
+
const { withEnv } = require("../../../../environment")
|
|
151
|
+
|
|
152
|
+
await withEnv(
|
|
153
|
+
{
|
|
154
|
+
LOGIN_MAX_FAILED_ATTEMPTS: 5,
|
|
155
|
+
LOGIN_LOCKOUT_SECONDS: 2,
|
|
156
|
+
},
|
|
157
|
+
async () => {
|
|
158
|
+
// 5 consecutive wrong attempts
|
|
159
|
+
for (let i = 0; i < 5; i++) {
|
|
160
|
+
await config.api.auth.login(tenantId, email, wrongPassword, {
|
|
161
|
+
status: 403,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// lock should be active now - further attempts (wrong or right) return 403
|
|
166
|
+
await config.api.auth.login(tenantId, email, wrongPassword, {
|
|
167
|
+
status: 403,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await config.api.auth.login(tenantId, email, correctPassword, {
|
|
171
|
+
status: 403,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// wait for TTL to expire (add buffer for redis expiration granularity)
|
|
175
|
+
await new Promise(r => setTimeout(r, 3000))
|
|
176
|
+
|
|
177
|
+
// Clear any remaining lockout state to ensure clean test
|
|
178
|
+
await config.doInTenant(async () => {
|
|
179
|
+
const { cache } = require("@budibase/backend-core")
|
|
180
|
+
const normalizeEmail = (e: string) => (e || "").toLowerCase()
|
|
181
|
+
const lockKey = (email: string) =>
|
|
182
|
+
`auth:login:lock:${normalizeEmail(email)}`
|
|
183
|
+
await cache.destroy(lockKey(email))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const response = await config.api.auth.login(
|
|
187
|
+
tenantId,
|
|
188
|
+
email,
|
|
189
|
+
correctPassword
|
|
190
|
+
)
|
|
191
|
+
expectSetAuthCookie(response)
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
143
196
|
})
|
|
144
197
|
|
|
145
198
|
describe("POST /api/global/auth/logout", () => {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { TestConfiguration } from "../../../../tests"
|
|
2
2
|
import { tenancy } from "@budibase/backend-core"
|
|
3
|
+
import { LockReason } from "@budibase/types"
|
|
4
|
+
import * as tenantSdk from "../../../../sdk/tenants"
|
|
5
|
+
|
|
6
|
+
jest.mock("../../../../sdk/tenants", () => ({
|
|
7
|
+
lockTenant: jest.fn(),
|
|
8
|
+
unlockTenant: jest.fn(),
|
|
9
|
+
deleteTenant: jest.fn(),
|
|
10
|
+
}))
|
|
3
11
|
|
|
4
12
|
describe("/api/global/tenants", () => {
|
|
5
13
|
const config = new TestConfiguration()
|
|
@@ -58,4 +66,85 @@ describe("/api/global/tenants", () => {
|
|
|
58
66
|
expect(res.body).toEqual(config.adminOnlyResponse())
|
|
59
67
|
})
|
|
60
68
|
})
|
|
69
|
+
|
|
70
|
+
describe("PUT /api/system/tenants/:tenantId/lock", () => {
|
|
71
|
+
it("allows locking tenant with valid reason", async () => {
|
|
72
|
+
const user = await config.createTenant()
|
|
73
|
+
|
|
74
|
+
await config.api.tenants.lock(
|
|
75
|
+
user.tenantId,
|
|
76
|
+
{
|
|
77
|
+
reason: LockReason.FREE_TIER,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
headers: config.internalAPIHeaders(),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(tenantSdk.lockTenant).toHaveBeenCalledWith(
|
|
85
|
+
user.tenantId,
|
|
86
|
+
LockReason.FREE_TIER
|
|
87
|
+
)
|
|
88
|
+
expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("unlocks tenant when no reason provided", async () => {
|
|
92
|
+
const user = await config.createTenant()
|
|
93
|
+
|
|
94
|
+
await config.api.tenants.lock(
|
|
95
|
+
user.tenantId,
|
|
96
|
+
{},
|
|
97
|
+
{
|
|
98
|
+
headers: config.internalAPIHeaders(),
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(tenantSdk.unlockTenant).toHaveBeenCalledWith(user.tenantId)
|
|
103
|
+
expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("rejects invalid lock reason", async () => {
|
|
107
|
+
const user = await config.createTenant()
|
|
108
|
+
|
|
109
|
+
const status = 400
|
|
110
|
+
const res = await config.api.tenants.lock(
|
|
111
|
+
user.tenantId,
|
|
112
|
+
{
|
|
113
|
+
reason: "invalid_reason" as any,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
status,
|
|
117
|
+
headers: config.internalAPIHeaders(),
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect(res.body.message).toContain("Invalid lock reason. Valid values:")
|
|
122
|
+
expect(res.body.message).toContain(LockReason.FREE_TIER)
|
|
123
|
+
expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
|
|
124
|
+
expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("rejects non-internal user", async () => {
|
|
128
|
+
const user = await config.createTenant()
|
|
129
|
+
|
|
130
|
+
const status = 403
|
|
131
|
+
const res = await config.api.tenants.lock(
|
|
132
|
+
user.tenantId,
|
|
133
|
+
{
|
|
134
|
+
reason: LockReason.FREE_TIER,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
status,
|
|
138
|
+
headers: config.authHeaders(user),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(res.body).toEqual({
|
|
143
|
+
message: "Only internal user can lock a tenant",
|
|
144
|
+
status,
|
|
145
|
+
})
|
|
146
|
+
expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
|
|
147
|
+
expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
61
150
|
})
|
package/src/environment.ts
CHANGED
|
@@ -87,6 +87,20 @@ const environment = {
|
|
|
87
87
|
PASSPORT_OIDCAUTH_FAILURE_REDIRECT:
|
|
88
88
|
process.env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT || "/error",
|
|
89
89
|
|
|
90
|
+
LOGIN_MAX_FAILED_ATTEMPTS:
|
|
91
|
+
parseIntSafe(process.env.LOGIN_MAX_FAILED_ATTEMPTS) || 5,
|
|
92
|
+
LOGIN_LOCKOUT_SECONDS: parseIntSafe(process.env.LOGIN_LOCKOUT_SECONDS) || 900,
|
|
93
|
+
|
|
94
|
+
// password reset rate limiting
|
|
95
|
+
PASSWORD_RESET_RATE_EMAIL_LIMIT:
|
|
96
|
+
parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_LIMIT) || 3,
|
|
97
|
+
PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS:
|
|
98
|
+
parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS) || 900,
|
|
99
|
+
PASSWORD_RESET_RATE_IP_LIMIT:
|
|
100
|
+
parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_LIMIT) || 20,
|
|
101
|
+
PASSWORD_RESET_RATE_IP_WINDOW_SECONDS:
|
|
102
|
+
parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS) || 900,
|
|
103
|
+
|
|
90
104
|
// Budibase AI
|
|
91
105
|
BUDIBASE_AI_API_KEY: process.env.BUDIBASE_AI_API_KEY,
|
|
92
106
|
BUDIBASE_AI_DEFAULT_MODEL: process.env.BUDIBASE_AI_DEFAULT_MODEL,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cache } from "@budibase/backend-core"
|
|
2
|
+
import { Ctx } from "@budibase/types"
|
|
3
|
+
import { Next } from "koa"
|
|
4
|
+
import env from "../environment"
|
|
5
|
+
import * as userSdk from "../sdk/users"
|
|
6
|
+
|
|
7
|
+
const normalizeEmail = (e: string) => (e || "").toLowerCase()
|
|
8
|
+
const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
|
|
9
|
+
|
|
10
|
+
const isLocked = async (email: string) => {
|
|
11
|
+
return !!(await cache.get(lockKey(email)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Middleware to check if an account is locked due to failed login attempts.
|
|
16
|
+
* If locked, returns 403 with appropriate headers.
|
|
17
|
+
*/
|
|
18
|
+
export default async (ctx: Ctx, next: Next) => {
|
|
19
|
+
const email = ctx.request.body.username
|
|
20
|
+
|
|
21
|
+
if (!email) {
|
|
22
|
+
return await next()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dbUser = await userSdk.db.getUserByEmail(email)
|
|
26
|
+
if (dbUser && (await isLocked(email))) {
|
|
27
|
+
console.log(
|
|
28
|
+
`[auth] login blocked due to lock email=${normalizeEmail(email)}`
|
|
29
|
+
)
|
|
30
|
+
ctx.set("X-Account-Locked", "1")
|
|
31
|
+
ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
|
|
32
|
+
ctx.throw(403, "Account temporarily locked. Try again later.")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return await next()
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import lockout from "../lockout"
|
|
2
|
+
import { cache } from "@budibase/backend-core"
|
|
3
|
+
import * as userSdk from "../../sdk/users"
|
|
4
|
+
import env from "../../environment"
|
|
5
|
+
|
|
6
|
+
jest.mock("@budibase/backend-core", () => ({
|
|
7
|
+
cache: {
|
|
8
|
+
get: jest.fn(),
|
|
9
|
+
},
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock("../../sdk/users", () => ({
|
|
13
|
+
db: {
|
|
14
|
+
getUserByEmail: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
describe("lockout middleware", () => {
|
|
19
|
+
let ctx: any
|
|
20
|
+
let next: jest.Mock
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
ctx = {
|
|
24
|
+
request: {
|
|
25
|
+
body: {},
|
|
26
|
+
},
|
|
27
|
+
set: jest.fn(),
|
|
28
|
+
throw: jest.fn(),
|
|
29
|
+
}
|
|
30
|
+
next = jest.fn()
|
|
31
|
+
jest.clearAllMocks()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("should call next if no email provided", async () => {
|
|
35
|
+
ctx.request.body = {}
|
|
36
|
+
|
|
37
|
+
await lockout(ctx, next)
|
|
38
|
+
|
|
39
|
+
expect(next).toHaveBeenCalled()
|
|
40
|
+
expect(ctx.throw).not.toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("should call next if user not found", async () => {
|
|
44
|
+
ctx.request.body = { username: "test@example.com" }
|
|
45
|
+
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue(null)
|
|
46
|
+
|
|
47
|
+
await lockout(ctx, next)
|
|
48
|
+
|
|
49
|
+
expect(next).toHaveBeenCalled()
|
|
50
|
+
expect(ctx.throw).not.toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("should call next if user exists but not locked", async () => {
|
|
54
|
+
ctx.request.body = { username: "test@example.com" }
|
|
55
|
+
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
|
|
56
|
+
email: "test@example.com",
|
|
57
|
+
})
|
|
58
|
+
;(cache.get as jest.Mock).mockResolvedValue(null)
|
|
59
|
+
|
|
60
|
+
await lockout(ctx, next)
|
|
61
|
+
|
|
62
|
+
expect(next).toHaveBeenCalled()
|
|
63
|
+
expect(ctx.throw).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("should throw 403 if user is locked", async () => {
|
|
67
|
+
ctx.request.body = { username: "test@example.com" }
|
|
68
|
+
;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
|
|
69
|
+
email: "test@example.com",
|
|
70
|
+
})
|
|
71
|
+
;(cache.get as jest.Mock).mockResolvedValue("1")
|
|
72
|
+
|
|
73
|
+
// Mock ctx.throw to actually throw
|
|
74
|
+
ctx.throw = jest.fn().mockImplementation((status, message) => {
|
|
75
|
+
const error = new Error(message)
|
|
76
|
+
;(error as any).status = status
|
|
77
|
+
throw error
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await expect(lockout(ctx, next)).rejects.toThrow(
|
|
81
|
+
"Account temporarily locked. Try again later."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(next).not.toHaveBeenCalled()
|
|
85
|
+
expect(ctx.set).toHaveBeenCalledWith("X-Account-Locked", "1")
|
|
86
|
+
expect(ctx.set).toHaveBeenCalledWith(
|
|
87
|
+
"Retry-After",
|
|
88
|
+
String(env.LOGIN_LOCKOUT_SECONDS)
|
|
89
|
+
)
|
|
90
|
+
expect(ctx.throw).toHaveBeenCalledWith(
|
|
91
|
+
403,
|
|
92
|
+
"Account temporarily locked. Try again later."
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
configs,
|
|
3
|
+
db as dbCore,
|
|
4
|
+
platform,
|
|
5
|
+
tenancy,
|
|
6
|
+
} from "@budibase/backend-core"
|
|
2
7
|
import { quotas } from "@budibase/pro"
|
|
8
|
+
import { ConfigType, LockReason, SettingsConfig } from "@budibase/types"
|
|
3
9
|
|
|
4
10
|
export async function deleteTenant(tenantId: string) {
|
|
5
11
|
await quotas.bustCache()
|
|
@@ -8,6 +14,28 @@ export async function deleteTenant(tenantId: string) {
|
|
|
8
14
|
await removeGlobalDB(tenantId)
|
|
9
15
|
}
|
|
10
16
|
|
|
17
|
+
export async function lockTenant(tenantId: string, lockReason: LockReason) {
|
|
18
|
+
return await setLock(tenantId, lockReason)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function unlockTenant(tenantId: string) {
|
|
22
|
+
return await setLock(tenantId)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function setLock(tenantId: string, lockReason?: LockReason) {
|
|
26
|
+
const db = tenancy.getTenantDB(tenantId)
|
|
27
|
+
const settingsConfig = await db.tryGet<SettingsConfig>(
|
|
28
|
+
configs.generateConfigID(ConfigType.SETTINGS)
|
|
29
|
+
)
|
|
30
|
+
if (!settingsConfig?.config) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Cannot lock. Settings config not found for tenant ${tenantId}`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
settingsConfig.config.lockedBy = lockReason
|
|
36
|
+
await db.put(settingsConfig)
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
async function removeGlobalDB(tenantId: string) {
|
|
12
40
|
try {
|
|
13
41
|
const db = tenancy.getTenantDB(tenantId)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { structures } from "../../../tests"
|
|
2
|
+
import { lockTenant, unlockTenant } from "../tenants"
|
|
3
|
+
import { configs, tenancy } from "@budibase/backend-core"
|
|
4
|
+
import { LockReason, ConfigType, SettingsConfig } from "@budibase/types"
|
|
5
|
+
|
|
6
|
+
// Mock the backend-core modules
|
|
7
|
+
jest.mock("@budibase/backend-core", () => {
|
|
8
|
+
const actual = jest.requireActual("@budibase/backend-core")
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
tenancy: {
|
|
12
|
+
...actual.tenancy,
|
|
13
|
+
getTenantDB: jest.fn(),
|
|
14
|
+
getGlobalDBName: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
configs: {
|
|
17
|
+
...actual.configs,
|
|
18
|
+
generateConfigID: jest.fn(),
|
|
19
|
+
},
|
|
20
|
+
db: {
|
|
21
|
+
...actual.db,
|
|
22
|
+
getAllWorkspaces: jest.fn(),
|
|
23
|
+
getDB: jest.fn(),
|
|
24
|
+
dbExists: jest.fn(),
|
|
25
|
+
getGlobalUserParams: jest.fn(),
|
|
26
|
+
},
|
|
27
|
+
platform: {
|
|
28
|
+
...actual.platform,
|
|
29
|
+
getPlatformDB: jest.fn(),
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Mock the pro module
|
|
35
|
+
jest.mock("@budibase/pro", () => ({
|
|
36
|
+
quotas: {
|
|
37
|
+
bustCache: jest.fn(),
|
|
38
|
+
},
|
|
39
|
+
constants: {
|
|
40
|
+
licenses: {
|
|
41
|
+
CLOUD_FREE_LICENSE: "CLOUD_FREE_LICENSE",
|
|
42
|
+
UNLIMITED_LICENSE: "UNLIMITED_LICENSE",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
licensing: {
|
|
46
|
+
cache: {
|
|
47
|
+
getCachedLicense: jest.fn(),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const mockDb = {
|
|
53
|
+
put: jest.fn(),
|
|
54
|
+
tryGet: jest.fn(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mockedTenancy = jest.mocked(tenancy)
|
|
58
|
+
const mockedConfigs = jest.mocked(configs)
|
|
59
|
+
|
|
60
|
+
describe("tenants", () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
jest.clearAllMocks()
|
|
63
|
+
// Setup mock database
|
|
64
|
+
mockedTenancy.getTenantDB.mockReturnValue(mockDb as any)
|
|
65
|
+
// Setup mock config ID generation
|
|
66
|
+
mockedConfigs.generateConfigID.mockReturnValue("config_settings")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("lockTenant", () => {
|
|
70
|
+
it("should lock tenant with provided reason", async () => {
|
|
71
|
+
const tenantId = structures.tenant.id()
|
|
72
|
+
const lockReason = LockReason.FREE_TIER
|
|
73
|
+
|
|
74
|
+
const settingsConfig: SettingsConfig = {
|
|
75
|
+
_id: "config_settings",
|
|
76
|
+
type: ConfigType.SETTINGS,
|
|
77
|
+
config: {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
mockDb.tryGet.mockResolvedValue(settingsConfig)
|
|
81
|
+
|
|
82
|
+
await lockTenant(tenantId, lockReason)
|
|
83
|
+
|
|
84
|
+
expect(mockDb.tryGet).toHaveBeenCalledWith(
|
|
85
|
+
configs.generateConfigID(ConfigType.SETTINGS)
|
|
86
|
+
)
|
|
87
|
+
expect(mockDb.put).toHaveBeenCalledWith({
|
|
88
|
+
...settingsConfig,
|
|
89
|
+
config: {
|
|
90
|
+
...settingsConfig.config,
|
|
91
|
+
lockedBy: lockReason,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("should throw error when settings config not found", async () => {
|
|
97
|
+
const tenantId = structures.tenant.id()
|
|
98
|
+
const lockReason = LockReason.FREE_TIER
|
|
99
|
+
|
|
100
|
+
mockDb.tryGet.mockResolvedValue(null)
|
|
101
|
+
|
|
102
|
+
await expect(lockTenant(tenantId, lockReason)).rejects.toThrow(
|
|
103
|
+
`Cannot lock. Settings config not found for tenant ${tenantId}`
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("should throw error when settings config has no config property", async () => {
|
|
108
|
+
const tenantId = structures.tenant.id()
|
|
109
|
+
const lockReason = LockReason.FREE_TIER
|
|
110
|
+
|
|
111
|
+
const settingsConfig = {
|
|
112
|
+
_id: "config_settings",
|
|
113
|
+
type: ConfigType.SETTINGS,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
mockDb.tryGet.mockResolvedValue(settingsConfig)
|
|
117
|
+
|
|
118
|
+
await expect(lockTenant(tenantId, lockReason)).rejects.toThrow(
|
|
119
|
+
`Cannot lock. Settings config not found for tenant ${tenantId}`
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("unlockTenant", () => {
|
|
125
|
+
it("should unlock tenant by removing lock reason", async () => {
|
|
126
|
+
const tenantId = structures.tenant.id()
|
|
127
|
+
|
|
128
|
+
const settingsConfig: SettingsConfig = {
|
|
129
|
+
_id: "config_settings",
|
|
130
|
+
type: ConfigType.SETTINGS,
|
|
131
|
+
config: {
|
|
132
|
+
lockedBy: LockReason.FREE_TIER,
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
mockDb.tryGet.mockResolvedValue(settingsConfig)
|
|
137
|
+
|
|
138
|
+
await unlockTenant(tenantId)
|
|
139
|
+
|
|
140
|
+
expect(mockDb.tryGet).toHaveBeenCalledWith(
|
|
141
|
+
configs.generateConfigID(ConfigType.SETTINGS)
|
|
142
|
+
)
|
|
143
|
+
expect(mockDb.put).toHaveBeenCalledWith({
|
|
144
|
+
...settingsConfig,
|
|
145
|
+
config: {
|
|
146
|
+
...settingsConfig.config,
|
|
147
|
+
lockedBy: undefined,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should throw error when settings config not found", async () => {
|
|
153
|
+
const tenantId = structures.tenant.id()
|
|
154
|
+
|
|
155
|
+
mockDb.tryGet.mockResolvedValue(null)
|
|
156
|
+
|
|
157
|
+
await expect(unlockTenant(tenantId)).rejects.toThrow(
|
|
158
|
+
`Cannot lock. Settings config not found for tenant ${tenantId}`
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
package/src/tests/api/tenants.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import TestConfiguration from "../TestConfiguration"
|
|
2
2
|
import { TestAPI, TestAPIOpts } from "./base"
|
|
3
|
+
import { LockRequest } from "@budibase/types"
|
|
3
4
|
|
|
4
5
|
export class TenantAPI extends TestAPI {
|
|
5
6
|
config: TestConfiguration
|
|
@@ -14,4 +15,12 @@ export class TenantAPI extends TestAPI {
|
|
|
14
15
|
.set(opts?.headers)
|
|
15
16
|
.expect(opts?.status ? opts.status : 204)
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
lock = (tenantId: string, body: LockRequest, opts?: TestAPIOpts) => {
|
|
20
|
+
return this.request
|
|
21
|
+
.put(`/api/system/tenants/${tenantId}/lock`)
|
|
22
|
+
.send(body)
|
|
23
|
+
.set(opts?.headers)
|
|
24
|
+
.expect(opts?.status ? opts.status : 204)
|
|
25
|
+
}
|
|
17
26
|
}
|