better-auth 1.6.20 → 1.6.22
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/dist/api/index.d.mts +3 -3
- package/dist/api/index.mjs +5 -5
- package/dist/api/rate-limiter/index.mjs +2 -3
- package/dist/api/routes/index.d.mts +1 -1
- package/dist/api/routes/session.d.mts +30 -3
- package/dist/api/routes/session.mjs +15 -4
- package/dist/context/create-context.mjs +6 -0
- package/dist/db/get-migration.mjs +1 -0
- package/dist/db/get-schema.d.mts +1 -0
- package/dist/db/get-schema.mjs +3 -1
- package/dist/db/index.d.mts +3 -2
- package/dist/db/index.mjs +5 -2
- package/dist/db/internal-adapter.mjs +1 -1
- package/dist/db/revoke-unproven-account-access.d.mts +19 -0
- package/dist/db/revoke-unproven-account-access.mjs +23 -0
- package/dist/db/schema.d.mts +2 -1
- package/dist/db/schema.mjs +13 -1
- package/dist/oauth2/link-account.mjs +20 -7
- package/dist/package.mjs +1 -1
- package/dist/plugins/admin/routes.mjs +4 -4
- package/dist/plugins/captcha/index.mjs +1 -1
- package/dist/plugins/device-authorization/index.d.mts +9 -9
- package/dist/plugins/device-authorization/index.mjs +1 -1
- package/dist/plugins/email-otp/routes.mjs +5 -1
- package/dist/plugins/magic-link/index.d.mts +3 -3
- package/dist/plugins/magic-link/index.mjs +6 -2
- package/dist/plugins/oauth-proxy/index.mjs +11 -6
- package/dist/plugins/one-tap/index.mjs +10 -9
- package/dist/plugins/siwe/index.mjs +5 -1
- package/dist/plugins/two-factor/backup-codes/index.mjs +21 -7
- package/dist/plugins/two-factor/client.d.mts +1 -0
- package/dist/plugins/two-factor/error-code.d.mts +1 -0
- package/dist/plugins/two-factor/error-code.mjs +1 -0
- package/dist/plugins/two-factor/index.d.mts +14 -0
- package/dist/plugins/two-factor/index.mjs +7 -1
- package/dist/plugins/two-factor/otp/index.mjs +17 -1
- package/dist/plugins/two-factor/schema.d.mts +13 -0
- package/dist/plugins/two-factor/schema.mjs +13 -0
- package/dist/plugins/two-factor/totp/index.mjs +23 -9
- package/dist/plugins/two-factor/types.d.mts +25 -0
- package/dist/plugins/two-factor/verify-two-factor.mjs +110 -3
- package/dist/plugins/username/index.mjs +56 -42
- package/package.json +9 -9
- package/dist/utils/get-request-ip.d.mts +0 -6
- package/dist/utils/get-request-ip.mjs +0 -20
|
@@ -2,6 +2,7 @@ import { originCheck } from "../../api/middlewares/origin-check.mjs";
|
|
|
2
2
|
import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
|
|
3
3
|
import { generateRandomString } from "../../crypto/random.mjs";
|
|
4
4
|
import { setSessionCookie } from "../../cookies/index.mjs";
|
|
5
|
+
import { revokeUnprovenAccountAccess } from "../../db/revoke-unproven-account-access.mjs";
|
|
5
6
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
6
7
|
import { defaultKeyHasher } from "./utils.mjs";
|
|
7
8
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
@@ -27,7 +28,7 @@ const magicLink = (options) => {
|
|
|
27
28
|
allowedAttempts: 1,
|
|
28
29
|
...options
|
|
29
30
|
};
|
|
30
|
-
if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call
|
|
31
|
+
if (options.allowedAttempts !== void 0 && options.allowedAttempts !== 1) console.warn("[better-auth/magic-link] `allowedAttempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning.");
|
|
31
32
|
async function storeToken(ctx, token) {
|
|
32
33
|
if (opts.storeToken === "hashed") return await defaultKeyHasher(token);
|
|
33
34
|
if (typeof opts.storeToken === "object" && "type" in opts.storeToken && opts.storeToken.type === "custom-hasher") return await opts.storeToken.hash(token);
|
|
@@ -134,7 +135,10 @@ const magicLink = (options) => {
|
|
|
134
135
|
user = newUser;
|
|
135
136
|
if (!user) redirectWithError("failed_to_create_user");
|
|
136
137
|
} else redirectWithError("new_user_signup_disabled");
|
|
137
|
-
if (!user.emailVerified)
|
|
138
|
+
if (!user.emailVerified) {
|
|
139
|
+
await revokeUnprovenAccountAccess(ctx, user.id);
|
|
140
|
+
user = await ctx.context.internalAdapter.updateUser(user.id, { emailVerified: true });
|
|
141
|
+
}
|
|
138
142
|
const session = await ctx.context.internalAdapter.createSession(user.id);
|
|
139
143
|
if (!session) redirectWithError("failed_to_create_session");
|
|
140
144
|
await setSessionCookie(ctx, {
|
|
@@ -14,6 +14,15 @@ import { defu } from "defu";
|
|
|
14
14
|
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
|
|
15
15
|
import * as z from "zod";
|
|
16
16
|
//#region src/plugins/oauth-proxy/index.ts
|
|
17
|
+
const consumeOAuthProxyState = async (ctx, state) => {
|
|
18
|
+
try {
|
|
19
|
+
await parseGenericState(ctx, state, { skipStateCookieCheck: true });
|
|
20
|
+
return true;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
ctx.context.logger.warn("OAuth proxy state missing or invalid", e);
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
17
26
|
const oauthProxyQuerySchema = z.object({
|
|
18
27
|
callbackURL: z.string().meta({ description: "The URL to redirect to after the proxy" }),
|
|
19
28
|
profile: z.string().optional().meta({ description: "Encrypted OAuth profile data" })
|
|
@@ -81,7 +90,7 @@ const oAuthProxy = (opts) => {
|
|
|
81
90
|
ctx.context.logger.error("Failed to parse OAuth proxy payload", e);
|
|
82
91
|
throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
|
|
83
92
|
}
|
|
84
|
-
if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.callbackURL) {
|
|
93
|
+
if (typeof payload.timestamp !== "number" || !payload.userInfo || !payload.account || !payload.state || !payload.callbackURL) {
|
|
85
94
|
ctx.context.logger.error("Failed to parse OAuth proxy payload");
|
|
86
95
|
throw redirectOnError(ctx, defaultErrorURL, "invalid_payload");
|
|
87
96
|
}
|
|
@@ -91,11 +100,7 @@ const oAuthProxy = (opts) => {
|
|
|
91
100
|
ctx.context.logger.error(`OAuth proxy payload expired or invalid (age: ${age}s, maxAge: ${maxAge}s)`);
|
|
92
101
|
throw redirectOnError(ctx, errorURL, "payload_expired");
|
|
93
102
|
}
|
|
94
|
-
|
|
95
|
-
await parseGenericState(ctx, payload.state, { skipStateCookieCheck: true });
|
|
96
|
-
} catch (e) {
|
|
97
|
-
ctx.context.logger.warn("Failed to clean up OAuth state", e);
|
|
98
|
-
}
|
|
103
|
+
if (!await consumeOAuthProxyState(ctx, payload.state)) throw redirectOnError(ctx, errorURL, "state_mismatch");
|
|
99
104
|
let result;
|
|
100
105
|
try {
|
|
101
106
|
result = await handleOAuthUserInfo(ctx, {
|
|
@@ -4,9 +4,9 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs";
|
|
|
4
4
|
import { APIError } from "../../api/index.mjs";
|
|
5
5
|
import { PACKAGE_VERSION } from "../../version.mjs";
|
|
6
6
|
import { toBoolean } from "../../utils/boolean.mjs";
|
|
7
|
+
import { isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "@better-auth/core/social-providers";
|
|
7
8
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
8
9
|
import * as z from "zod";
|
|
9
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
10
10
|
//#region src/plugins/one-tap/index.ts
|
|
11
11
|
const oneTapCallbackBodySchema = z.object({
|
|
12
12
|
idToken: z.string().meta({ description: "Google ID token, which the client obtains from the One Tap API" }),
|
|
@@ -40,14 +40,15 @@ const oneTap = (options) => ({
|
|
|
40
40
|
const googleProvider = typeof ctx.context.options.socialProviders?.google === "function" ? await ctx.context.options.socialProviders?.google() : ctx.context.options.socialProviders?.google;
|
|
41
41
|
const audience = options?.clientId || googleProvider?.clientId;
|
|
42
42
|
if (!audience || Array.isArray(audience) && audience.length === 0) throw new APIError("BAD_REQUEST", { message: "Google client ID is required for One Tap. Set it on the oneTap plugin (clientId) or on socialProviders.google." });
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
const payload = await verifyGoogleIdToken({
|
|
44
|
+
token: idToken,
|
|
45
|
+
audience
|
|
46
|
+
});
|
|
47
|
+
if (!payload) throw new APIError("BAD_REQUEST", { message: "invalid id token" });
|
|
48
|
+
if (!payload.sub) throw new APIError("BAD_REQUEST", { message: "invalid id token" });
|
|
49
|
+
const configuredHostedDomain = googleProvider?.hd;
|
|
50
|
+
if (!isGoogleHostedDomainAllowed(configuredHostedDomain, payload.hd)) {
|
|
51
|
+
ctx.context.logger.error(`Google One Tap sign-in rejected: id token hosted domain (hd) "${payload.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${configuredHostedDomain}".`);
|
|
51
52
|
throw new APIError("BAD_REQUEST", { message: "invalid id token" });
|
|
52
53
|
}
|
|
53
54
|
const { email: rawEmail, email_verified, name, picture, sub } = payload;
|
|
@@ -166,7 +166,11 @@ const siwe = (options) => {
|
|
|
166
166
|
}
|
|
167
167
|
if (!user) {
|
|
168
168
|
const domain = options.emailDomainName ?? getOrigin(ctx.context.baseURL);
|
|
169
|
-
const
|
|
169
|
+
const normalizedEmail = email?.toLowerCase();
|
|
170
|
+
let userEmail = `${walletAddress}@${domain}`;
|
|
171
|
+
if (!isAnon && normalizedEmail) {
|
|
172
|
+
if (!await ctx.context.internalAdapter.findUserByEmail(normalizedEmail)) userEmail = normalizedEmail;
|
|
173
|
+
}
|
|
170
174
|
const { name, avatar } = await options.ensLookup?.({ walletAddress }) ?? {};
|
|
171
175
|
user = await ctx.context.internalAdapter.createUser({
|
|
172
176
|
name: name ?? walletAddress,
|
|
@@ -5,7 +5,7 @@ import { sessionMiddleware } from "../../../api/routes/session.mjs";
|
|
|
5
5
|
import { shouldRequirePassword } from "../../../utils/password.mjs";
|
|
6
6
|
import { PACKAGE_VERSION } from "../../../version.mjs";
|
|
7
7
|
import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
|
|
8
|
-
import { verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
8
|
+
import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
9
9
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
10
10
|
import { safeJSONParse } from "@better-auth/core/utils/json";
|
|
11
11
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
@@ -161,8 +161,9 @@ const backupCode2fa = (opts) => {
|
|
|
161
161
|
} }
|
|
162
162
|
} }
|
|
163
163
|
}, async (ctx) => {
|
|
164
|
-
const { session, valid } = await verifyTwoFactor(ctx);
|
|
164
|
+
const { session, valid, beginAttempt } = await verifyTwoFactor(ctx);
|
|
165
165
|
const user = session.user;
|
|
166
|
+
const isSignIn = !session.session;
|
|
166
167
|
const twoFactor = await ctx.context.adapter.findOne({
|
|
167
168
|
model: twoFactorTable,
|
|
168
169
|
where: [{
|
|
@@ -171,11 +172,23 @@ const backupCode2fa = (opts) => {
|
|
|
171
172
|
}]
|
|
172
173
|
});
|
|
173
174
|
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
|
|
176
|
+
const attempt = isSignIn ? await beginAttempt(5) : null;
|
|
177
|
+
let validate;
|
|
178
|
+
try {
|
|
179
|
+
validate = await verifyBackupCode({
|
|
180
|
+
backupCodes: twoFactor.backupCodes,
|
|
181
|
+
code: ctx.body.code
|
|
182
|
+
}, ctx.context.secretConfig, opts);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await attempt?.restore();
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
if (!validate.status || !validate.updated) {
|
|
188
|
+
await attempt?.recordFailure();
|
|
189
|
+
if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
|
|
190
|
+
throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
|
|
191
|
+
}
|
|
179
192
|
const updatedBackupCodes = await encodeBackupCodes(validate.updated, ctx.context.secretConfig, opts);
|
|
180
193
|
if (!await ctx.context.adapter.incrementOne({
|
|
181
194
|
model: twoFactorTable,
|
|
@@ -189,6 +202,7 @@ const backupCode2fa = (opts) => {
|
|
|
189
202
|
increment: {},
|
|
190
203
|
set: { backupCodes: updatedBackupCodes }
|
|
191
204
|
})) throw APIError.fromStatus("CONFLICT", { message: "Failed to verify backup code. Please try again." });
|
|
205
|
+
if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
|
|
192
206
|
if (!ctx.body.disableSession) return valid(ctx);
|
|
193
207
|
return ctx.json({
|
|
194
208
|
token: session.session?.token,
|
|
@@ -65,6 +65,7 @@ declare const twoFactorClient: (options?: {
|
|
|
65
65
|
INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
|
|
66
66
|
INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
|
|
67
67
|
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
|
|
68
|
+
ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
|
|
68
69
|
INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
|
|
69
70
|
};
|
|
70
71
|
};
|
|
@@ -10,6 +10,7 @@ declare const TWO_FACTOR_ERROR_CODES: {
|
|
|
10
10
|
INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
|
|
11
11
|
INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
|
|
12
12
|
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
|
|
13
|
+
ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
|
|
13
14
|
INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
|
|
14
15
|
};
|
|
15
16
|
//#endregion
|
|
@@ -9,6 +9,7 @@ const TWO_FACTOR_ERROR_CODES = defineErrorCodes({
|
|
|
9
9
|
INVALID_BACKUP_CODE: "Invalid backup code",
|
|
10
10
|
INVALID_CODE: "Invalid code",
|
|
11
11
|
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "Too many attempts. Please request a new code.",
|
|
12
|
+
ACCOUNT_TEMPORARILY_LOCKED: "Too many failed verification attempts. Your account is temporarily locked. Please try again later.",
|
|
12
13
|
INVALID_TWO_FACTOR_COOKIE: "Invalid two factor cookie"
|
|
13
14
|
});
|
|
14
15
|
//#endregion
|
|
@@ -662,6 +662,19 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
|
|
|
662
662
|
defaultValue: true;
|
|
663
663
|
input: false;
|
|
664
664
|
};
|
|
665
|
+
failedVerificationCount: {
|
|
666
|
+
type: "number";
|
|
667
|
+
required: false;
|
|
668
|
+
defaultValue: number;
|
|
669
|
+
input: false;
|
|
670
|
+
returned: false;
|
|
671
|
+
};
|
|
672
|
+
lockedUntil: {
|
|
673
|
+
type: "date";
|
|
674
|
+
required: false;
|
|
675
|
+
input: false;
|
|
676
|
+
returned: false;
|
|
677
|
+
};
|
|
665
678
|
};
|
|
666
679
|
};
|
|
667
680
|
};
|
|
@@ -679,6 +692,7 @@ declare const twoFactor: <O extends TwoFactorOptions>(options?: O) => {
|
|
|
679
692
|
INVALID_BACKUP_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_BACKUP_CODE">;
|
|
680
693
|
INVALID_CODE: _better_auth_core_utils_error_codes0.RawError<"INVALID_CODE">;
|
|
681
694
|
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: _better_auth_core_utils_error_codes0.RawError<"TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE">;
|
|
695
|
+
ACCOUNT_TEMPORARILY_LOCKED: _better_auth_core_utils_error_codes0.RawError<"ACCOUNT_TEMPORARILY_LOCKED">;
|
|
682
696
|
INVALID_TWO_FACTOR_COOKIE: _better_auth_core_utils_error_codes0.RawError<"INVALID_TWO_FACTOR_COOKIE">;
|
|
683
697
|
};
|
|
684
698
|
};
|
|
@@ -236,10 +236,16 @@ const twoFactor = (options) => {
|
|
|
236
236
|
const maxAge = options?.twoFactorCookieMaxAge ?? 600;
|
|
237
237
|
const twoFactorCookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME, { maxAge });
|
|
238
238
|
const identifier = `2fa-${generateRandomString(20)}`;
|
|
239
|
+
const expiresAt = new Date(Date.now() + maxAge * 1e3);
|
|
239
240
|
await ctx.context.internalAdapter.createVerificationValue({
|
|
240
241
|
value: data.user.id,
|
|
241
242
|
identifier,
|
|
242
|
-
expiresAt
|
|
243
|
+
expiresAt
|
|
244
|
+
});
|
|
245
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
246
|
+
value: "0",
|
|
247
|
+
identifier: `2fa-attempts-${identifier}`,
|
|
248
|
+
expiresAt
|
|
243
249
|
});
|
|
244
250
|
await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes);
|
|
245
251
|
const twoFactorMethods = [];
|
|
@@ -5,7 +5,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto/index.mjs";
|
|
|
5
5
|
import { setSessionCookie } from "../../../cookies/index.mjs";
|
|
6
6
|
import { PACKAGE_VERSION } from "../../../version.mjs";
|
|
7
7
|
import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
|
|
8
|
-
import { verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
8
|
+
import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
9
9
|
import { defaultKeyHasher } from "../utils.mjs";
|
|
10
10
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
11
11
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
@@ -158,6 +158,20 @@ const otp2fa = (options) => {
|
|
|
158
158
|
} }
|
|
159
159
|
}, async (ctx) => {
|
|
160
160
|
const { session, key, valid, invalid } = await verifyTwoFactor(ctx);
|
|
161
|
+
const isSignIn = !session.session;
|
|
162
|
+
const twoFactorTable = "twoFactor";
|
|
163
|
+
let twoFactor = null;
|
|
164
|
+
if (isSignIn) {
|
|
165
|
+
twoFactor = await ctx.context.adapter.findOne({
|
|
166
|
+
model: twoFactorTable,
|
|
167
|
+
where: [{
|
|
168
|
+
field: "userId",
|
|
169
|
+
value: session.user.id
|
|
170
|
+
}]
|
|
171
|
+
});
|
|
172
|
+
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED);
|
|
173
|
+
await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
|
|
174
|
+
}
|
|
161
175
|
const consumed = await ctx.context.internalAdapter.consumeVerificationValue(`2fa-otp-${key}`);
|
|
162
176
|
if (!consumed) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED);
|
|
163
177
|
const [otp, counter] = consumed.value?.split(":") ?? [];
|
|
@@ -166,6 +180,7 @@ const otp2fa = (options) => {
|
|
|
166
180
|
if (attempts >= allowedAttempts) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
|
|
167
181
|
const [storedValue, inputValue] = await decryptOrHashForComparison(ctx, otp, ctx.body.code);
|
|
168
182
|
if (constantTimeEqual(new TextEncoder().encode(storedValue), new TextEncoder().encode(inputValue))) {
|
|
183
|
+
if (twoFactor) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
|
|
169
184
|
if (!session.user.twoFactorEnabled) {
|
|
170
185
|
if (!session.session) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
|
|
171
186
|
const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { twoFactorEnabled: true });
|
|
@@ -187,6 +202,7 @@ const otp2fa = (options) => {
|
|
|
187
202
|
identifier: `2fa-otp-${key}`,
|
|
188
203
|
expiresAt: consumed.expiresAt
|
|
189
204
|
});
|
|
205
|
+
if (twoFactor) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
|
|
190
206
|
return invalid("INVALID_CODE");
|
|
191
207
|
})
|
|
192
208
|
}
|
|
@@ -39,6 +39,19 @@ declare const schema: {
|
|
|
39
39
|
defaultValue: true;
|
|
40
40
|
input: false;
|
|
41
41
|
};
|
|
42
|
+
failedVerificationCount: {
|
|
43
|
+
type: "number";
|
|
44
|
+
required: false;
|
|
45
|
+
defaultValue: number;
|
|
46
|
+
input: false;
|
|
47
|
+
returned: false;
|
|
48
|
+
};
|
|
49
|
+
lockedUntil: {
|
|
50
|
+
type: "date";
|
|
51
|
+
required: false;
|
|
52
|
+
input: false;
|
|
53
|
+
returned: false;
|
|
54
|
+
};
|
|
42
55
|
};
|
|
43
56
|
};
|
|
44
57
|
};
|
|
@@ -33,6 +33,19 @@ const schema = {
|
|
|
33
33
|
required: false,
|
|
34
34
|
defaultValue: true,
|
|
35
35
|
input: false
|
|
36
|
+
},
|
|
37
|
+
failedVerificationCount: {
|
|
38
|
+
type: "number",
|
|
39
|
+
required: false,
|
|
40
|
+
defaultValue: 0,
|
|
41
|
+
input: false,
|
|
42
|
+
returned: false
|
|
43
|
+
},
|
|
44
|
+
lockedUntil: {
|
|
45
|
+
type: "date",
|
|
46
|
+
required: false,
|
|
47
|
+
input: false,
|
|
48
|
+
returned: false
|
|
36
49
|
}
|
|
37
50
|
} }
|
|
38
51
|
};
|
|
@@ -4,7 +4,7 @@ import { sessionMiddleware } from "../../../api/routes/session.mjs";
|
|
|
4
4
|
import { shouldRequirePassword } from "../../../utils/password.mjs";
|
|
5
5
|
import { PACKAGE_VERSION } from "../../../version.mjs";
|
|
6
6
|
import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
|
|
7
|
-
import { verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
7
|
+
import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
|
|
8
8
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
9
9
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
10
10
|
import * as z from "zod";
|
|
@@ -122,7 +122,7 @@ const totp2fa = (options) => {
|
|
|
122
122
|
code: "TOTP_NOT_CONFIGURED"
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
|
-
const { session, valid, invalid } = await verifyTwoFactor(ctx);
|
|
125
|
+
const { session, valid, invalid, beginAttempt } = await verifyTwoFactor(ctx);
|
|
126
126
|
const user = session.user;
|
|
127
127
|
const isSignIn = !session.session;
|
|
128
128
|
const twoFactor = await ctx.context.adapter.findOne({
|
|
@@ -134,13 +134,27 @@ const totp2fa = (options) => {
|
|
|
134
134
|
});
|
|
135
135
|
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
|
|
136
136
|
if (isSignIn && twoFactor.verified === false) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED);
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
|
|
138
|
+
const attempt = isSignIn ? await beginAttempt(5) : null;
|
|
139
|
+
let status;
|
|
140
|
+
try {
|
|
141
|
+
status = await createOTP(await symmetricDecrypt({
|
|
142
|
+
key: ctx.context.secretConfig,
|
|
143
|
+
data: twoFactor.secret
|
|
144
|
+
}), {
|
|
145
|
+
period: opts.period,
|
|
146
|
+
digits: opts.digits
|
|
147
|
+
}).verify(ctx.body.code);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await attempt?.restore();
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (!status) {
|
|
153
|
+
await attempt?.recordFailure();
|
|
154
|
+
if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
|
|
155
|
+
return invalid("INVALID_CODE");
|
|
156
|
+
}
|
|
157
|
+
if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
|
|
144
158
|
if (twoFactor.verified !== true) {
|
|
145
159
|
if (!user.twoFactorEnabled) {
|
|
146
160
|
const activeSession = session.session;
|
|
@@ -63,6 +63,29 @@ interface TwoFactorOptions {
|
|
|
63
63
|
* @default 2592000 (30 days)
|
|
64
64
|
*/
|
|
65
65
|
trustDeviceMaxAge?: number | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Account-level lockout for failed second-factor verifications during
|
|
68
|
+
* sign-in. Caps consecutive failed attempts per account, across challenges
|
|
69
|
+
* and factors, and resets on a successful verification.
|
|
70
|
+
*/
|
|
71
|
+
accountLockout?: {
|
|
72
|
+
/**
|
|
73
|
+
* Whether account-level lockout is enforced.
|
|
74
|
+
* @default true
|
|
75
|
+
*/
|
|
76
|
+
enabled?: boolean | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Consecutive failed verifications, across challenges and factors,
|
|
79
|
+
* before the account is locked.
|
|
80
|
+
* @default 10
|
|
81
|
+
*/
|
|
82
|
+
maxFailedAttempts?: number | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* How long the account stays locked, in seconds.
|
|
85
|
+
* @default 900 (15 minutes)
|
|
86
|
+
*/
|
|
87
|
+
durationSeconds?: number | undefined;
|
|
88
|
+
} | undefined;
|
|
66
89
|
}
|
|
67
90
|
interface UserWithTwoFactor extends User {
|
|
68
91
|
/**
|
|
@@ -81,6 +104,8 @@ interface TwoFactorTable {
|
|
|
81
104
|
secret: string;
|
|
82
105
|
backupCodes: string;
|
|
83
106
|
verified: boolean;
|
|
107
|
+
failedVerificationCount?: number;
|
|
108
|
+
lockedUntil?: Date | null;
|
|
84
109
|
}
|
|
85
110
|
//#endregion
|
|
86
111
|
export { TwoFactorOptions, TwoFactorProvider, TwoFactorTable, UserWithTwoFactor };
|
|
@@ -66,7 +66,28 @@ async function verifyTwoFactor(ctx) {
|
|
|
66
66
|
session: null,
|
|
67
67
|
user
|
|
68
68
|
},
|
|
69
|
-
key: signedTwoFactorCookie
|
|
69
|
+
key: signedTwoFactorCookie,
|
|
70
|
+
beginAttempt: async (allowedAttempts) => {
|
|
71
|
+
const identifier = `2fa-attempts-${signedTwoFactorCookie}`;
|
|
72
|
+
const consumed = await ctx.context.internalAdapter.consumeVerificationValue(identifier).catch(() => null);
|
|
73
|
+
if (!consumed) throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE);
|
|
74
|
+
const parsed = Number(consumed.value);
|
|
75
|
+
const attempts = Number.isInteger(parsed) && parsed >= 0 ? parsed : allowedAttempts;
|
|
76
|
+
if (attempts >= allowedAttempts) {
|
|
77
|
+
await ctx.context.internalAdapter.consumeVerificationValue(signedTwoFactorCookie).catch(() => {});
|
|
78
|
+
expireCookie(ctx, twoFactorCookie);
|
|
79
|
+
throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE);
|
|
80
|
+
}
|
|
81
|
+
const rearm = (count) => ctx.context.internalAdapter.createVerificationValue({
|
|
82
|
+
value: `${count}`,
|
|
83
|
+
identifier,
|
|
84
|
+
expiresAt: verificationToken.expiresAt
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
return {
|
|
87
|
+
recordFailure: () => rearm(attempts + 1),
|
|
88
|
+
restore: () => rearm(attempts)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
70
91
|
};
|
|
71
92
|
}
|
|
72
93
|
return {
|
|
@@ -78,8 +99,94 @@ async function verifyTwoFactor(ctx) {
|
|
|
78
99
|
},
|
|
79
100
|
invalid,
|
|
80
101
|
session,
|
|
81
|
-
key: `${session.user.id}!${session.session.id}
|
|
102
|
+
key: `${session.user.id}!${session.session.id}`,
|
|
103
|
+
beginAttempt: async (_allowedAttempts) => ({
|
|
104
|
+
recordFailure: async () => {},
|
|
105
|
+
restore: async () => {}
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function resolveAccountLockoutConfig(ctx) {
|
|
110
|
+
const lockout = (ctx.context.getPlugin("two-factor")?.options)?.accountLockout;
|
|
111
|
+
return {
|
|
112
|
+
enabled: lockout?.enabled ?? true,
|
|
113
|
+
maxFailedAttempts: lockout?.maxFailedAttempts ?? 10,
|
|
114
|
+
durationMs: (lockout?.durationSeconds ?? 900) * 1e3
|
|
82
115
|
};
|
|
83
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Reject the verification when the account is locked, and lazily clear an
|
|
119
|
+
* expired lock. The lock caps consecutive failed verifications per account,
|
|
120
|
+
* across challenges and factors (NIST SP 800-63B §5.2.2).
|
|
121
|
+
*/
|
|
122
|
+
async function assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor) {
|
|
123
|
+
const { enabled } = resolveAccountLockoutConfig(ctx);
|
|
124
|
+
if (!enabled || !twoFactor.lockedUntil) return;
|
|
125
|
+
if (new Date(twoFactor.lockedUntil).getTime() > Date.now()) throw APIError.from("TOO_MANY_REQUESTS", TWO_FACTOR_ERROR_CODES.ACCOUNT_TEMPORARILY_LOCKED);
|
|
126
|
+
await ctx.context.adapter.incrementOne({
|
|
127
|
+
model: twoFactorTable,
|
|
128
|
+
where: [{
|
|
129
|
+
field: "id",
|
|
130
|
+
value: twoFactor.id
|
|
131
|
+
}, {
|
|
132
|
+
field: "lockedUntil",
|
|
133
|
+
operator: "lte",
|
|
134
|
+
value: /* @__PURE__ */ new Date()
|
|
135
|
+
}],
|
|
136
|
+
increment: {},
|
|
137
|
+
set: {
|
|
138
|
+
failedVerificationCount: 0,
|
|
139
|
+
lockedUntil: null
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Count one failed verification toward the account-level budget, and lock the
|
|
145
|
+
* account once the budget is spent. The increment is atomic, so concurrent
|
|
146
|
+
* failures cannot lose updates. It is unguarded so it still applies to a row
|
|
147
|
+
* whose counter is null or absent (a row created before the migration, or a
|
|
148
|
+
* document-store record predating the column), where a guarded comparison
|
|
149
|
+
* would never match.
|
|
150
|
+
*/
|
|
151
|
+
async function recordTwoFactorFailure(ctx, twoFactorTable, twoFactor) {
|
|
152
|
+
const { enabled, maxFailedAttempts, durationMs } = resolveAccountLockoutConfig(ctx);
|
|
153
|
+
if (!enabled) return;
|
|
154
|
+
if (((await ctx.context.adapter.incrementOne({
|
|
155
|
+
model: twoFactorTable,
|
|
156
|
+
where: [{
|
|
157
|
+
field: "id",
|
|
158
|
+
value: twoFactor.id
|
|
159
|
+
}],
|
|
160
|
+
increment: { failedVerificationCount: 1 }
|
|
161
|
+
}))?.failedVerificationCount ?? 0) >= maxFailedAttempts) await ctx.context.adapter.update({
|
|
162
|
+
model: twoFactorTable,
|
|
163
|
+
where: [{
|
|
164
|
+
field: "id",
|
|
165
|
+
value: twoFactor.id
|
|
166
|
+
}],
|
|
167
|
+
update: { lockedUntil: new Date(Date.now() + durationMs) }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Clear the account-level failure budget after a successful verification, so the
|
|
172
|
+
* count tracks only consecutive failures. The write is unconditional: a snapshot
|
|
173
|
+
* read at the start of the request can miss a concurrent failure, so skipping it
|
|
174
|
+
* could leave the counter non-zero after a success.
|
|
175
|
+
*/
|
|
176
|
+
async function resetTwoFactorFailures(ctx, twoFactorTable, twoFactor) {
|
|
177
|
+
const { enabled } = resolveAccountLockoutConfig(ctx);
|
|
178
|
+
if (!enabled) return;
|
|
179
|
+
await ctx.context.adapter.update({
|
|
180
|
+
model: twoFactorTable,
|
|
181
|
+
where: [{
|
|
182
|
+
field: "id",
|
|
183
|
+
value: twoFactor.id
|
|
184
|
+
}],
|
|
185
|
+
update: {
|
|
186
|
+
failedVerificationCount: 0,
|
|
187
|
+
lockedUntil: null
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
84
191
|
//#endregion
|
|
85
|
-
export { verifyTwoFactor };
|
|
192
|
+
export { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor };
|