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 { 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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({}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
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