mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-10 12:27:44 +00:00
feat(phone-number): add phone number verification requirement before sign-in (#1984)
* feat(phone-number): add phone number verification requirement before sign in * docs: add doc
This commit is contained in:
@@ -204,6 +204,7 @@ export const auth = betterAuth({
|
|||||||
- `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email.
|
- `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email.
|
||||||
- `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name.
|
- `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name.
|
||||||
|
|
||||||
|
- `requireVerification`: When enabled, users cannot sign in with their phone number until it has been verified. If an unverified user attempts to sign in, the server will respond with a 401 error (PHONE_NUMBER_NOT_VERIFIED) and automatically trigger an OTP send to start the verification process.
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getDate } from "../../utils/date";
|
|||||||
import { setSessionCookie } from "../../cookies";
|
import { setSessionCookie } from "../../cookies";
|
||||||
import { BASE_ERROR_CODES } from "../../error/codes";
|
import { BASE_ERROR_CODES } from "../../error/codes";
|
||||||
import type { User } from "../../types";
|
import type { User } from "../../types";
|
||||||
|
import { ERROR_CODES } from "./phone-number-error";
|
||||||
|
|
||||||
export interface UserWithPhoneNumber extends User {
|
export interface UserWithPhoneNumber extends User {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
@@ -62,6 +63,12 @@ export interface PhoneNumberOptions {
|
|||||||
* by default any string is accepted
|
* by default any string is accepted
|
||||||
*/
|
*/
|
||||||
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
|
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Require a phone number verification before signing in
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
requireVerification?: boolean;
|
||||||
/**
|
/**
|
||||||
* Callback when phone number is verified
|
* Callback when phone number is verified
|
||||||
*/
|
*/
|
||||||
@@ -122,15 +129,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
|
|||||||
createdAt: "createdAt",
|
createdAt: "createdAt",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ERROR_CODES = {
|
|
||||||
INVALID_PHONE_NUMBER: "Invalid phone number",
|
|
||||||
PHONE_NUMBER_EXIST: "Phone number already exist",
|
|
||||||
INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password",
|
|
||||||
UNEXPECTED_ERROR: "Unexpected error",
|
|
||||||
OTP_NOT_FOUND: "OTP not found",
|
|
||||||
OTP_EXPIRED: "OTP expired",
|
|
||||||
INVALID_OTP: "Invalid OTP",
|
|
||||||
} as const;
|
|
||||||
return {
|
return {
|
||||||
id: "phone-number",
|
id: "phone-number",
|
||||||
endpoints: {
|
endpoints: {
|
||||||
@@ -209,6 +207,23 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
|
|||||||
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
|
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (opts.requireVerification) {
|
||||||
|
if (!user.phoneNumberVerified) {
|
||||||
|
const otp = generateOTP(opts.otpLength);
|
||||||
|
await ctx.context.internalAdapter.createVerificationValue({
|
||||||
|
value: otp,
|
||||||
|
identifier: phoneNumber,
|
||||||
|
expiresAt: getDate(opts.expiresIn, "sec"),
|
||||||
|
});
|
||||||
|
await opts.sendOTP?.({
|
||||||
|
phoneNumber,
|
||||||
|
code: otp,
|
||||||
|
});
|
||||||
|
throw new APIError("UNAUTHORIZED", {
|
||||||
|
message: ERROR_CODES.PHONE_NUMBER_NOT_VERIFIED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const accounts =
|
const accounts =
|
||||||
await ctx.context.internalAdapter.findAccountByUserId(user.id);
|
await ctx.context.internalAdapter.findAccountByUserId(user.id);
|
||||||
const credentialAccount = accounts.find(
|
const credentialAccount = accounts.find(
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const ERROR_CODES = {
|
||||||
|
INVALID_PHONE_NUMBER: "Invalid phone number",
|
||||||
|
PHONE_NUMBER_EXIST: "Phone number already exist",
|
||||||
|
INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password",
|
||||||
|
UNEXPECTED_ERROR: "Unexpected error",
|
||||||
|
OTP_NOT_FOUND: "OTP not found",
|
||||||
|
OTP_EXPIRED: "OTP expired",
|
||||||
|
INVALID_OTP: "Invalid OTP",
|
||||||
|
PHONE_NUMBER_NOT_VERIFIED: "Phone number not verified",
|
||||||
|
} as const;
|
||||||
@@ -370,3 +370,55 @@ describe("reset password flow attempts", async (it) => {
|
|||||||
expect(res.data?.status).toBe(true);
|
expect(res.data?.status).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("phone number verification requirement", async () => {
|
||||||
|
let otp = "";
|
||||||
|
const { customFetchImpl } = await getTestInstance({
|
||||||
|
plugins: [
|
||||||
|
phoneNumber({
|
||||||
|
async sendOTP({ code }) {
|
||||||
|
otp = code;
|
||||||
|
},
|
||||||
|
requireVerification: true,
|
||||||
|
signUpOnVerification: {
|
||||||
|
getTempEmail(phoneNumber) {
|
||||||
|
return `temp-${phoneNumber}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
user: {
|
||||||
|
changeEmail: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createAuthClient({
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
plugins: [phoneNumberClient()],
|
||||||
|
fetchOptions: {
|
||||||
|
customFetchImpl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testPhoneNumber = "+251911121314";
|
||||||
|
const testPassword = "password123";
|
||||||
|
const testEmail = "test2@test.com";
|
||||||
|
|
||||||
|
it("should not allow sign in with unverified phone number and trigger OTP send", async () => {
|
||||||
|
await client.signUp.email({
|
||||||
|
email: testEmail,
|
||||||
|
password: testPassword,
|
||||||
|
name: "test",
|
||||||
|
phoneNumber: testPhoneNumber,
|
||||||
|
});
|
||||||
|
const signInRes = await client.signIn.phoneNumber({
|
||||||
|
phoneNumber: testPhoneNumber,
|
||||||
|
password: testPassword,
|
||||||
|
});
|
||||||
|
expect(signInRes.error?.status).toBe(401);
|
||||||
|
expect(signInRes.error?.code).toMatch("PHONE_NUMBER_NOT_VERIFIED");
|
||||||
|
expect(otp).toHaveLength(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user