v3: added password change/reset feature

This commit is contained in:
khannurien
2026-04-06 16:30:00 +00:00
parent 3b6980a8fc
commit 20b9bfe7b4
26 changed files with 1268 additions and 236 deletions

View File

@@ -19,6 +19,11 @@ export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
// Set to 127.0.0.1 to restrict to loopback only.
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
// In single-container deployments the API serves the frontend, so FRONTEND_URL
// equals BASE_URL. Override with GERBEUR_FRONTEND_URL when running the frontend
// on a separate host/port (e.g. Vite dev server or a dedicated CDN origin).
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
BASE_URL;
export const DB_PATH = "api/sql/gerbeur.db";
// Upload/files

View File

@@ -6,6 +6,8 @@ import {
InvitePayload,
isAuthPayload,
isInvitePayload,
isPasswordResetPayload,
type PasswordResetPayload,
} from "../model/interfaces.ts";
import { JWT_SECRET } from "../config.ts";
@@ -38,6 +40,32 @@ export async function verifyInviteToken(
}
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export async function createPasswordResetToken(
userId: string,
): Promise<string> {
return await new SignJWT({ purpose: "password-reset", userId })
.setProtectedHeader({ alg: "HS256" })
.setJti(crypto.randomUUID())
.setExpirationTime("1h")
.sign(JWT_KEY);
}
export async function verifyPasswordResetToken(
token: string,
): Promise<PasswordResetPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (!isPasswordResetPayload(payload)) return null;
return payload as PasswordResetPayload;
} catch {
return null;
}
}
// ── Auth tokens ───────────────────────────────────────────────────────────────
export async function createJWT(
payload: Omit<AuthPayload, "exp">,
): Promise<string> {

View File

@@ -380,6 +380,27 @@ export function notificationRowToApi(row: NotificationRow): Notification {
};
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export interface PasswordResetTokenRow {
token: string;
user_id: string;
expires_at: string;
used_at: string | null;
[key: string]: SQLOutputValue;
}
export function isPasswordResetTokenRow(
obj: unknown,
): obj is PasswordResetTokenRow {
return !!obj && typeof obj === "object" &&
"token" in obj && typeof obj.token === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"expires_at" in obj && typeof obj.expires_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}
// ── Invites ───────────────────────────────────────────────────────────────────
export interface InviteRow {

View File

@@ -177,6 +177,22 @@ export function isInvitePayload(obj: unknown): obj is InvitePayload {
typeof (obj as Record<string, unknown>).inviterId === "string";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;
exp: number;
}
export function isPasswordResetPayload(
obj: unknown,
): obj is PasswordResetPayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj &&
(obj as Record<string, unknown>).purpose === "password-reset" &&
"userId" in obj &&
typeof (obj as Record<string, unknown>).userId === "string";
}
/**
* API
*/

View File

@@ -22,8 +22,17 @@ import {
updateUser,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts";
import {
FROM_EMAIL,
OG_SITE_NAME,
VALIDATION,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {
@@ -163,6 +172,74 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
}
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json();
const email = typeof body?.email === "string" ? body.email.trim() : "";
if (email) {
await requestPasswordReset(email).catch((err) =>
console.error("[request-password-reset]", err)
);
}
ctx.response.body = { success: true };
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body = await ctx.request.body.json();
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
await resetPassword(token, newPassword);
ctx.response.body = { success: true };
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
const { currentPassword, newPassword } = (body ?? {}) as Record<
string,
unknown
>;
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}${VALIDATION.PASSWORD_MAX} characters`,
);
}
const user = getUserById(ctx.state.user.userId);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Current password is incorrect",
);
}
await updateUser(ctx.state.user.userId, { password: newPassword });
ctx.response.body = { success: true };
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();

View File

@@ -0,0 +1,109 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isPasswordResetTokenRow } from "../model/db.ts";
import {
createPasswordResetToken,
verifyPasswordResetToken,
} from "../lib/jwt.ts";
import { getUserByEmail, updateUser } from "./user-service.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import {
FROM_EMAIL,
FRONTEND_URL,
OG_SITE_NAME,
VALIDATION,
} from "../config.ts";
import { marked } from "marked";
const RESET_TOKEN_TTL_HOURS = 1;
/**
* Looks up the user by email, creates a signed reset token, persists it, and
* sends the reset link. Always resolves without throwing so callers can return
* 200 unconditionally (no user enumeration).
*/
export async function requestPasswordReset(email: string): Promise<void> {
const user = getUserByEmail(email);
if (!user) return;
if (!isEmailEnabled()) return;
const token = await createPasswordResetToken(user.id);
const expiresAt = new Date(
Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000,
);
db.prepare(
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, expiresAt.toISOString());
const resetUrl = `${FRONTEND_URL}/reset-password?token=${
encodeURIComponent(token)
}`;
const body =
`# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`;
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Reset your ${OG_SITE_NAME} password`,
text: body,
html: await marked(body),
}).catch((err) =>
console.error("[password-reset] failed to send email:", err)
);
}
/**
* Verifies the token (JWT signature + expiry + DB record + not used), updates
* the password, and marks the token consumed.
*/
export async function resetPassword(
token: string,
newPassword: string,
): Promise<void> {
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}${VALIDATION.PASSWORD_MAX} characters`,
);
}
const payload = await verifyPasswordResetToken(token);
if (!payload) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid or expired reset link",
);
}
const row = db.prepare(
`SELECT token, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?;`,
).get(token);
if (!row || !isPasswordResetTokenRow(row)) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Reset token not found",
);
}
if (row.used_at !== null) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Reset link has already been used",
);
}
await updateUser(payload.userId, { password: newPassword });
db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ?;`,
).run(new Date().toISOString(), token);
}

View File

@@ -85,6 +85,15 @@ export function getUserByUsername(username: string): User {
return userRowToApi(userRow);
}
export function getUserByEmail(email: string): User | null {
const userRow = db.prepare(
`${USER_SELECT} WHERE u.email = ?`,
).get(email);
if (!userRow || !isUserRow(userRow)) return null;
return userRowToApi(userRow);
}
export function searchUsers(
query: string,
limit: number,

View File

@@ -126,6 +126,14 @@ CREATE TABLE attachments (
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,