v3: added password change/reset feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
109
api/services/password-reset-service.ts
Normal file
109
api/services/password-reset-service.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user