feat(api): Implement password resets

This commit is contained in:
Jesse Wierzbinski 2024-05-16 22:27:41 -10:00
parent 1365987a1c
commit 24288c95b5
No known key found for this signature in database
11 changed files with 1998 additions and 19 deletions

View file

@ -4,6 +4,7 @@ import chalk from "chalk";
import { renderUnicodeCompact } from "uqr"; import { renderUnicodeCompact } from "uqr";
import { UserFinderCommand } from "~cli/classes"; import { UserFinderCommand } from "~cli/classes";
import { formatArray } from "~cli/utils/format"; import { formatArray } from "~cli/utils/format";
import { config } from "~packages/config-manager";
export default class UserReset extends UserFinderCommand<typeof UserReset> { export default class UserReset extends UserFinderCommand<typeof UserReset> {
static override description = "Resets users' passwords"; static override description = "Resets users' passwords";
@ -84,30 +85,41 @@ export default class UserReset extends UserFinderCommand<typeof UserReset> {
} }
} }
const link = "https://example.com/reset-password"; for (const user of users) {
const token = await user.resetPassword();
const link = new URL(
`${config.frontend.routes.password_reset}?${new URLSearchParams(
{
token,
},
).toString()}`,
config.http.base_url,
).toString();
!flags.raw &&
this.log(
`${chalk.green("✓")} Password reset for ${
users.length
} user(s)`,
);
!flags.raw &&
this.log( this.log(
`${chalk.green("✓")} Password reset for ${ flags.raw
users.length ? link
} user(s)`, : `\nPassword reset link for ${chalk.bold(
"@testuser",
)}: ${chalk.underline(chalk.blue(link))}\n`,
); );
this.log( const qrcode = renderUnicodeCompact(link, {
flags.raw border: 2,
? link });
: `\nPassword reset link for ${chalk.bold(
"@testuser",
)}: ${chalk.underline(chalk.blue(link))}\n`,
);
const qrcode = renderUnicodeCompact(link, { // Pad all lines of QR code with spaces
border: 2,
});
// Pad all lines of QR code with spaces !flags.raw && this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
}
!flags.raw && this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
this.exit(0); this.exit(0);
} }

View file

@ -118,6 +118,7 @@ url = "http://localhost:3000"
# login = "/oauth/authorize" # login = "/oauth/authorize"
# consent = "/oauth/consent" # consent = "/oauth/consent"
# register = "/register" # register = "/register"
# password_reset = "/oauth/reset"
[frontend.settings] [frontend.settings]
# Arbitrary key/value pairs to be passed to the frontend # Arbitrary key/value pairs to be passed to the frontend

View file

@ -0,0 +1,2 @@
ALTER TABLE "Users" ADD COLUMN "email_verification_token" text;--> statement-breakpoint
ALTER TABLE "Users" ADD COLUMN "password_reset_token" text;

File diff suppressed because it is too large Load diff

View file

@ -162,6 +162,13 @@
"when": 1715563390152, "when": 1715563390152,
"tag": "0022_curly_the_call", "tag": "0022_curly_the_call",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1715932436792,
"tag": "0023_lazy_wolfsbane",
"breakpoints": true
} }
] ]
} }

View file

@ -352,6 +352,8 @@ export const Users = pgTable(
email: text("email"), email: text("email"),
note: text("note").default("").notNull(), note: text("note").default("").notNull(),
isAdmin: boolean("is_admin").default(false).notNull(), isAdmin: boolean("is_admin").default(false).notNull(),
emailVerificationToken: text("email_verification_token"),
passwordResetToken: text("password_reset_token"),
fields: jsonb("fields").notNull().default("[]").$type< fields: jsonb("fields").notNull().default("[]").$type<
{ {
key: typeof EntityValidator.$ContentFormat; key: typeof EntityValidator.$ContentFormat;

View file

@ -169,12 +169,14 @@ export const configValidator = z.object({
login: zUrlPath.default("/oauth/authorize"), login: zUrlPath.default("/oauth/authorize"),
consent: zUrlPath.default("/oauth/consent"), consent: zUrlPath.default("/oauth/consent"),
register: zUrlPath.default("/register"), register: zUrlPath.default("/register"),
password_reset: zUrlPath.default("/oauth/reset"),
}) })
.default({ .default({
home: "/", home: "/",
login: "/oauth/authorize", login: "/oauth/authorize",
consent: "/oauth/consent", consent: "/oauth/consent",
register: "/register", register: "/register",
password_reset: "/oauth/reset",
}), }),
settings: z.record(z.string(), z.any()).default({}), settings: z.record(z.string(), z.any()).default({}),
}) })

View file

@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { idValidator } from "@api"; import { idValidator } from "@api";
import { getBestContentType, urlToContentFormat } from "@content_types"; import { getBestContentType, urlToContentFormat } from "@content_types";
import type { EntityValidator } from "@lysand-org/federation"; import type { EntityValidator } from "@lysand-org/federation";
@ -150,6 +151,16 @@ export class User {
)[0]; )[0];
} }
async resetPassword() {
const resetToken = await randomBytes(32).toString("hex");
await this.update({
passwordResetToken: resetToken,
});
return resetToken;
}
async pin(note: Note) { async pin(note: Note) {
return ( return (
await db await db

View file

@ -1,6 +1,6 @@
import { applyConfig, handleZodError } from "@api"; import { applyConfig, handleZodError } from "@api";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { errorResponse, response } from "@response"; import { errorResponse, redirect, response } from "@response";
import { eq, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
@ -119,6 +119,20 @@ export default (app: Hono) =>
"Invalid identifier or password", "Invalid identifier or password",
); );
if (user.getUser().passwordResetToken) {
return response(null, 302, {
Location: new URL(
`${
config.frontend.routes.password_reset
}?${new URLSearchParams({
token: user.getUser().passwordResetToken ?? "",
login_reset: "true",
}).toString()}`,
config.http.base_url,
).toString(),
});
}
// Try and import the key // Try and import the key
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",

View file

@ -64,6 +64,20 @@ export default (app: Hono) =>
) )
return redirectToLogin("Invalid email or password"); return redirectToLogin("Invalid email or password");
if (user.getUser().passwordResetToken) {
return response(null, 302, {
Location: new URL(
`${
config.frontend.routes.password_reset
}?${new URLSearchParams({
token: user.getUser().passwordResetToken ?? "",
login_reset: "true",
}).toString()}`,
config.http.base_url,
).toString(),
});
}
const code = randomBytes(32).toString("hex"); const code = randomBytes(32).toString("hex");
const accessToken = randomBytes(64).toString("base64url"); const accessToken = randomBytes(64).toString("base64url");

View file

@ -0,0 +1,81 @@
import { applyConfig, handleZodError } from "@api";
import { zValidator } from "@hono/zod-validator";
import { response } from "@response";
import { eq } from "drizzle-orm";
import type { Hono } from "hono";
import { z } from "zod";
import { Users } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import { User } from "~packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/api/auth/reset",
auth: {
required: false,
},
});
export const schemas = {
form: z.object({
token: z.string().min(1),
password: z.string().min(3).max(100),
password2: z.string().min(3).max(100),
}),
};
const returnError = (token: string, error: string, description: string) => {
const searchParams = new URLSearchParams();
searchParams.append("error", error);
searchParams.append("error_description", description);
searchParams.append("token", token);
return response(null, 302, {
Location: new URL(
`${
config.frontend.routes.password_reset
}?${searchParams.toString()}`,
config.http.base_url,
).toString(),
});
};
export default (app: Hono) =>
app.on(
meta.allowedMethods,
meta.route,
zValidator("form", schemas.form, handleZodError),
async (context) => {
const { token, password, password2 } = context.req.valid("form");
const user = await User.fromSql(
eq(Users.passwordResetToken, token),
);
if (!user) {
return returnError(token, "invalid_token", "Invalid token");
}
if (password !== password2) {
return returnError(
token,
"password_mismatch",
"Passwords do not match",
);
}
await user.update({
password: await Bun.password.hash(password),
passwordResetToken: null,
});
return response(null, 302, {
Location: `${config.frontend.routes.password_reset}?success=true`,
});
},
);