mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-26 08:56:40 -05:00
fix: support creating credential account on forget password (#191)
This commit is contained in:
@@ -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>`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user