fix: support creating credential account on forget password (#191)

This commit is contained in:
Bereket Engida
2024-10-16 12:49:44 +03:00
committed by GitHub
parent 351a3c883f
commit 86ea2def73
7 changed files with 116 additions and 113 deletions

View File

@@ -65,6 +65,11 @@ List of all the available options for configuring Better Auth.
description: "Send reset password token to user's email.",
type: '(token: string, user: User) => Promise<void>',
},
resetPasswordTokenExpiresIn: {
description: "Expiration time for the reset password token. The value should be in seconds.",
type: 'number',
default: 60 * 60 * 1 // 1 hour
},
sendVerificationEmail: {
description: "Send verification email to user's email. It takes email and data that contains `url` and `token` props.",
type: `(email: string, data: Data) => Promise<void>`

View File

@@ -8,7 +8,7 @@ describe("forget password", async (it) => {
emailAndPassword: {
enabled: true,
async sendResetPassword(url, user) {
token = url.split("/").pop() || "";
token = url.split("?")[0].split("/").pop() || "";
await mockSendEmail();
},
},
@@ -29,10 +29,11 @@ describe("forget password", async (it) => {
},
{
query: {
currentURL: `http://localhost:3000/reset-password?token=${token}`,
token,
},
},
);
expect(res.data).toMatchObject({
status: true,
});
@@ -50,4 +51,20 @@ describe("forget password", async (it) => {
});
expect(newCred.data?.session).toBeDefined();
});
it("shouldn't allow the token to be used twice", async () => {
const newPassword = "new-password";
const res = await client.resetPassword(
{
newPassword,
},
{
query: {
token,
},
},
);
expect(res.error?.status).toBe(400);
});
});

View File

@@ -1,6 +1,3 @@
import { TimeSpan } from "oslo";
import { createJWT, parseJWT, type JWT } from "oslo/jwt";
import { validateJWT } from "oslo/jwt";
import { z } from "zod";
import { createAuthEndpoint } from "../call";
import { APIError } from "better-call";
@@ -54,22 +51,20 @@ export const forgetPassword = createAuthEndpoint(
},
);
}
const token = await createJWT(
"HS256",
Buffer.from(ctx.context.secret),
{
email: user.user.email,
redirectTo: redirectTo,
},
{
expiresIn: new TimeSpan(1, "h"),
issuer: "better-auth",
subject: "forget-password",
audiences: [user.user.email],
includeIssuedTimestamp: true,
},
const defaultExpiresIn = 60 * 60 * 1;
const expiresAt = new Date(
Date.now() +
1000 *
(ctx.context.options.emailAndPassword.resetPasswordTokenExpiresIn ||
defaultExpiresIn),
);
const url = `${ctx.context.baseURL}/reset-password/${token}`;
const verificationToken = ctx.context.uuid();
await ctx.context.internalAdapter.createVerificationValue({
value: user.user.id,
identifier: `reset-password:${verificationToken}`,
expiresAt,
});
const url = `${ctx.context.baseURL}/reset-password/${verificationToken}?callbackURL=${redirectTo}`;
await ctx.context.options.emailAndPassword.sendResetPassword(
url,
user.user,
@@ -84,33 +79,27 @@ export const forgetPasswordCallback = createAuthEndpoint(
"/reset-password/:token",
{
method: "GET",
query: z.object({
callbackURL: z.string(),
}),
use: [redirectURLMiddleware],
},
async (ctx) => {
const { token } = ctx.params;
let decodedToken: JWT;
const schema = z.object({
email: z.string(),
redirectTo: z.string(),
});
try {
decodedToken = await validateJWT(
"HS256",
Buffer.from(ctx.context.secret),
token,
);
if (!decodedToken.expiresAt || decodedToken.expiresAt < new Date()) {
throw Error("Token expired");
}
} catch (e) {
const decoded = parseJWT(token);
const jwt = schema.safeParse(decoded?.payload);
if (jwt.success) {
throw ctx.redirect(`${jwt.data?.redirectTo}?error=invalid_token`);
} else {
throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_token`);
}
const callbackURL = ctx.query.callbackURL;
const redirectTo = callbackURL.startsWith("http")
? callbackURL
: `${ctx.context.options.baseURL}${callbackURL}`;
if (!token || !callbackURL) {
throw ctx.redirect(`${ctx.context.baseURL}/error?error=INVALID_TOKEN`);
}
const verification =
await ctx.context.internalAdapter.findVerificationValue(
`reset-password:${token}`,
);
if (!verification || verification.expiresAt < new Date()) {
throw ctx.redirect(`${redirectTo}?error=INVALID_TOKEN`);
}
const { redirectTo } = schema.parse(decodedToken.payload);
throw ctx.redirect(`${redirectTo}?token=${token}`);
},
);
@@ -118,92 +107,60 @@ export const forgetPasswordCallback = createAuthEndpoint(
export const resetPassword = createAuthEndpoint(
"/reset-password",
{
method: "POST",
query: z
.object({
currentURL: z.string(),
token: z.string(),
})
.optional(),
method: "POST",
body: z.object({
newPassword: z.string(),
callbackURL: z.string().optional(),
}),
},
async (ctx) => {
const token = ctx.query?.currentURL.split("?token=")[1];
const token = ctx.query?.token;
if (!token) {
throw new APIError("BAD_REQUEST", {
message: "Token not found",
});
}
const { newPassword } = ctx.body;
try {
const jwt = await validateJWT(
"HS256",
Buffer.from(ctx.context.secret),
token,
);
const email = z
.string()
.email()
.parse((jwt.payload as { email: string }).email);
const user = await ctx.context.internalAdapter.findUserByEmail(email);
if (!user) {
return ctx.json(
{
error: "User not found",
data: null,
},
{
status: 400,
body: {
message: "failed to reset password",
},
},
);
}
if (
newPassword.length <
(ctx.context.options.emailAndPassword?.minPasswordLength || 8) ||
newPassword.length >
(ctx.context.options.emailAndPassword?.maxPasswordLength || 32)
) {
throw new APIError("BAD_REQUEST", {
message: "Password is too short or too long",
});
}
const hashedPassword = await ctx.context.password.hash(newPassword);
const updatedUser = await ctx.context.internalAdapter.updatePassword(
user.user.id,
hashedPassword,
);
if (!updatedUser) {
throw new APIError("BAD_REQUEST", {
message: "Failed to update password",
});
}
return ctx.json(
{
error: null,
data: {
status: true,
url: ctx.body.callbackURL,
redirect: !!ctx.body.callbackURL,
},
},
{
body: {
status: true,
url: ctx.body.callbackURL,
redirect: !!ctx.body.callbackURL,
},
},
);
} catch (e) {
ctx.context.logger.error("Failed to reset password", e);
const id = `reset-password:${token}`;
const verification =
await ctx.context.internalAdapter.findVerificationValue(id);
if (!verification || verification.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
message: "Failed to reset password",
message: "Invalid token",
});
}
await ctx.context.internalAdapter.deleteVerificationValue(verification.id);
const userId = verification.value;
const hashedPassword = await ctx.context.password.hash(newPassword);
const accounts = await ctx.context.internalAdapter.findAccounts(userId);
const account = accounts.find((ac) => ac.providerId === "credential");
if (!account) {
await ctx.context.internalAdapter.createAccount({
userId,
providerId: "credential",
password: hashedPassword,
accountId: ctx.context.uuid(),
});
return ctx.json({
status: true,
});
}
const updatedUser = await ctx.context.internalAdapter.updatePassword(
userId,
hashedPassword,
);
if (!updatedUser) {
throw new APIError("BAD_REQUEST", {
message: "Failed to update password",
});
}
return ctx.json({
status: true,
});
},
);

View File

@@ -49,6 +49,22 @@ export const createInternalAdapter = (
);
return createdUser as T & User;
},
createAccount: async <T>(
account: Omit<Account, "id" | "createdAt" | "updatedAt"> &
Partial<Account> &
Record<string, any>,
) => {
const createdAccount = await createWithHooks(
{
id: generateId(),
createdAt: new Date(),
updatedAt: new Date(),
...account,
},
"account",
);
return createdAccount as T & Account;
},
listSessions: async (userId: string) => {
const sessions = await adapter.findMany<Session>({
model: tables.session.tableName,

View File

@@ -22,6 +22,7 @@ import { createLogger, logger } from "./utils/logger";
import { socialProviderList, socialProviders } from "./social-providers";
import { BetterAuthError } from "./error";
import type { OAuthProvider } from "./oauth2";
import { generateId } from "./utils";
export const init = async (options: BetterAuthOptions) => {
const adapter = await getAdapter(options);
@@ -104,6 +105,7 @@ export const init = async (options: BetterAuthOptions) => {
disabled: options.logger?.disabled || false,
}),
db,
uuid: generateId,
secondaryStorage: options.secondaryStorage,
password: {
hash: options.emailAndPassword?.password?.hash || hashPassword,
@@ -147,6 +149,7 @@ export type AuthContext = {
updateAge: number;
expiresIn: number;
};
uuid: (size?: number) => string;
secondaryStorage: SecondaryStorage | undefined;
password: {
hash: (password: string) => Promise<string>;

View File

@@ -171,7 +171,7 @@ export const admin = (options?: AdminOptions) => {
email: z.string(),
password: z.string(),
name: z.string(),
role: z.enum(["user", "admin"]),
role: z.string(),
/**
* extra fields for user
*/

View File

@@ -114,6 +114,11 @@ export interface BetterAuthOptions {
* send reset password email
*/
sendResetPassword?: (url: string, user: User) => Promise<void>;
/**
* Number of seconds the reset password token is valid for.
* @default 1 hour
*/
resetPasswordTokenExpiresIn?: number;
/**
* @param user the user to send the verification email to
* @param url the url to send the verification email to