mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Implement password resets
This commit is contained in:
parent
1365987a1c
commit
24288c95b5
|
|
@ -4,6 +4,7 @@ import chalk from "chalk";
|
|||
import { renderUnicodeCompact } from "uqr";
|
||||
import { UserFinderCommand } from "~cli/classes";
|
||||
import { formatArray } from "~cli/utils/format";
|
||||
import { config } from "~packages/config-manager";
|
||||
|
||||
export default class UserReset extends UserFinderCommand<typeof UserReset> {
|
||||
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(
|
||||
`${chalk.green("✓")} Password reset for ${
|
||||
users.length
|
||||
} user(s)`,
|
||||
flags.raw
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
"@testuser",
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
|
||||
this.log(
|
||||
flags.raw
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
"@testuser",
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
// Pad all lines of QR code with spaces
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ url = "http://localhost:3000"
|
|||
# login = "/oauth/authorize"
|
||||
# consent = "/oauth/consent"
|
||||
# register = "/register"
|
||||
# password_reset = "/oauth/reset"
|
||||
|
||||
[frontend.settings]
|
||||
# Arbitrary key/value pairs to be passed to the frontend
|
||||
|
|
|
|||
2
drizzle/0023_lazy_wolfsbane.sql
Normal file
2
drizzle/0023_lazy_wolfsbane.sql
Normal 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;
|
||||
1833
drizzle/meta/0023_snapshot.json
Normal file
1833
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -162,6 +162,13 @@
|
|||
"when": 1715563390152,
|
||||
"tag": "0022_curly_the_call",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1715932436792,
|
||||
"tag": "0023_lazy_wolfsbane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,8 @@ export const Users = pgTable(
|
|||
email: text("email"),
|
||||
note: text("note").default("").notNull(),
|
||||
isAdmin: boolean("is_admin").default(false).notNull(),
|
||||
emailVerificationToken: text("email_verification_token"),
|
||||
passwordResetToken: text("password_reset_token"),
|
||||
fields: jsonb("fields").notNull().default("[]").$type<
|
||||
{
|
||||
key: typeof EntityValidator.$ContentFormat;
|
||||
|
|
|
|||
|
|
@ -169,12 +169,14 @@ export const configValidator = z.object({
|
|||
login: zUrlPath.default("/oauth/authorize"),
|
||||
consent: zUrlPath.default("/oauth/consent"),
|
||||
register: zUrlPath.default("/register"),
|
||||
password_reset: zUrlPath.default("/oauth/reset"),
|
||||
})
|
||||
.default({
|
||||
home: "/",
|
||||
login: "/oauth/authorize",
|
||||
consent: "/oauth/consent",
|
||||
register: "/register",
|
||||
password_reset: "/oauth/reset",
|
||||
}),
|
||||
settings: z.record(z.string(), z.any()).default({}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import { idValidator } from "@api";
|
||||
import { getBestContentType, urlToContentFormat } from "@content_types";
|
||||
import type { EntityValidator } from "@lysand-org/federation";
|
||||
|
|
@ -150,6 +151,16 @@ export class User {
|
|||
)[0];
|
||||
}
|
||||
|
||||
async resetPassword() {
|
||||
const resetToken = await randomBytes(32).toString("hex");
|
||||
|
||||
await this.update({
|
||||
passwordResetToken: resetToken,
|
||||
});
|
||||
|
||||
return resetToken;
|
||||
}
|
||||
|
||||
async pin(note: Note) {
|
||||
return (
|
||||
await db
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { applyConfig, handleZodError } from "@api";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { errorResponse, response } from "@response";
|
||||
import { errorResponse, redirect, response } from "@response";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import type { Hono } from "hono";
|
||||
import { SignJWT } from "jose";
|
||||
|
|
@ -119,6 +119,20 @@ export default (app: Hono) =>
|
|||
"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
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ export default (app: Hono) =>
|
|||
)
|
||||
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 accessToken = randomBytes(64).toString("base64url");
|
||||
|
||||
|
|
|
|||
81
server/api/api/auth/reset/index.ts
Normal file
81
server/api/api/auth/reset/index.ts
Normal 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`,
|
||||
});
|
||||
},
|
||||
);
|
||||
Loading…
Reference in a new issue