mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-10 04:19:32 +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.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDate } from "../../utils/date";
|
||||
import { setSessionCookie } from "../../cookies";
|
||||
import { BASE_ERROR_CODES } from "../../error/codes";
|
||||
import type { User } from "../../types";
|
||||
import { ERROR_CODES } from "./phone-number-error";
|
||||
|
||||
export interface UserWithPhoneNumber extends User {
|
||||
phoneNumber: string;
|
||||
@@ -62,6 +63,12 @@ export interface PhoneNumberOptions {
|
||||
* by default any string is accepted
|
||||
*/
|
||||
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Require a phone number verification before signing in
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
requireVerification?: boolean;
|
||||
/**
|
||||
* Callback when phone number is verified
|
||||
*/
|
||||
@@ -122,15 +129,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
|
||||
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 {
|
||||
id: "phone-number",
|
||||
endpoints: {
|
||||
@@ -209,6 +207,23 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
|
||||
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 =
|
||||
await ctx.context.internalAdapter.findAccountByUserId(user.id);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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