This commit is contained in:
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys } from "../../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const DELETE: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: "Passkey ID is required" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(passkeys)
|
||||
.where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id)));
|
||||
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: "Failed to delete passkey" }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
};
|
||||
102
src/pages/api/auth/passkey/login/finish.ts
Normal file
102
src/pages/api/auth/passkey/login/finish.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import { createSession } from "../../../../../lib/auth";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
|
||||
const passkey = await db.query.passkeys.findFirst({
|
||||
where: eq(passkeys.id, id),
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
return new Response(JSON.stringify({ error: "Passkey not found" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, passkey.userId),
|
||||
});
|
||||
|
||||
if (!user) return new Response(null, { status: 400 });
|
||||
|
||||
const clientDataJSON = Buffer.from(
|
||||
body.response.clientDataJSON,
|
||||
"base64url",
|
||||
).toString("utf-8");
|
||||
const clientData = JSON.parse(clientDataJSON);
|
||||
const challenge = clientData.challenge;
|
||||
|
||||
const dbChallenge = await db.query.passkeyChallenges.findFirst({
|
||||
where: and(
|
||||
eq(passkeyChallenges.challenge, challenge),
|
||||
gt(passkeyChallenges.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!dbChallenge) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid or expired challenge" }),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challenge as string,
|
||||
expectedOrigin: new URL(request.url).origin,
|
||||
expectedRPID: new URL(request.url).hostname,
|
||||
credential: {
|
||||
id: passkey.id,
|
||||
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports
|
||||
? JSON.parse(passkey.transports)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (verification.verified) {
|
||||
const { authenticationInfo } = verification;
|
||||
|
||||
await db
|
||||
.update(passkeys)
|
||||
.set({
|
||||
counter: authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(eq(passkeys.id, passkey.id));
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(user.id);
|
||||
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(eq(passkeyChallenges.challenge, challenge));
|
||||
|
||||
return new Response(JSON.stringify({ verified: true }));
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false }), { status: 400 });
|
||||
};
|
||||
18
src/pages/api/auth/passkey/login/start.ts
Normal file
18
src/pages/api/auth/passkey/login/start.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeyChallenges } from "../../../../../db/schema";
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: new URL(request.url).hostname,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
await db.insert(passkeyChallenges).values({
|
||||
challenge: options.challenge,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(options));
|
||||
};
|
||||
81
src/pages/api/auth/passkey/register/finish.ts
Normal file
81
src/pages/api/auth/passkey/register/finish.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
const clientDataJSON = Buffer.from(
|
||||
body.response.clientDataJSON,
|
||||
"base64url",
|
||||
).toString("utf-8");
|
||||
const clientData = JSON.parse(clientDataJSON);
|
||||
const challenge = clientData.challenge;
|
||||
|
||||
const dbChallenge = await db.query.passkeyChallenges.findFirst({
|
||||
where: and(
|
||||
eq(passkeyChallenges.challenge, challenge),
|
||||
eq(passkeyChallenges.userId, user.id),
|
||||
gt(passkeyChallenges.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!dbChallenge) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid or expired challenge" }),
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: new URL(request.url).origin,
|
||||
expectedRPID: new URL(request.url).hostname,
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { registrationInfo } = verification;
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
registrationInfo;
|
||||
|
||||
await db.insert(passkeys).values({
|
||||
id: credential.id,
|
||||
userId: user.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString("base64"),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: body.response.transports
|
||||
? JSON.stringify(body.response.transports)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await db
|
||||
.delete(passkeyChallenges)
|
||||
.where(eq(passkeyChallenges.challenge, challenge));
|
||||
|
||||
return new Response(JSON.stringify({ verified: true }));
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false }), { status: 400 });
|
||||
};
|
||||
45
src/pages/api/auth/passkey/register/start.ts
Normal file
45
src/pages/api/auth/passkey/register/start.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||
import { db } from "../../../../../db";
|
||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's existing passkeys to prevent registering the same authenticator twice
|
||||
const userPasskeys = await db.query.passkeys.findMany({
|
||||
where: eq(passkeys.userId, user.id),
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: "Chronus",
|
||||
rpID: new URL(request.url).hostname,
|
||||
userName: user.email,
|
||||
attestationType: "none",
|
||||
excludeCredentials: userPasskeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
transports: passkey.transports
|
||||
? JSON.parse(passkey.transports)
|
||||
: undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await db.insert(passkeyChallenges).values({
|
||||
challenge: options.challenge,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(options));
|
||||
};
|
||||
@@ -1,61 +1,104 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('currentPassword') as string;
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
let currentPassword, newPassword, confirmPassword;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
currentPassword = body.currentPassword;
|
||||
newPassword = body.newPassword;
|
||||
confirmPassword = body.confirmPassword;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
currentPassword = formData.get("currentPassword") as string;
|
||||
newPassword = formData.get("newPassword") as string;
|
||||
confirmPassword = formData.get("confirmPassword") as string;
|
||||
}
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return new Response('All fields are required', { status: 400 });
|
||||
const msg = "All fields are required";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return new Response('New passwords do not match', { status: 400 });
|
||||
const msg = "New passwords do not match";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return new Response('Password must be at least 8 characters', { status: 400 });
|
||||
const msg = "Password must be at least 8 characters";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user from database
|
||||
const dbUser = await db.select()
|
||||
const dbUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.get();
|
||||
|
||||
if (!dbUser) {
|
||||
return new Response('User not found', { status: 404 });
|
||||
const msg = "User not found";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 404 });
|
||||
return new Response(msg, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
currentPassword,
|
||||
dbUser.passwordHash,
|
||||
);
|
||||
if (!passwordMatch) {
|
||||
return new Response('Current password is incorrect', { status: 400 });
|
||||
const msg = "Current password is incorrect";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: hashedPassword })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=password');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
return redirect("/dashboard/settings?success=password");
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
return new Response('Failed to change password', { status: 500 });
|
||||
console.error("Error changing password:", error);
|
||||
const msg = "Failed to change password";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name")?.toString();
|
||||
let name: string | undefined;
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return new Response(JSON.stringify({ error: "Name is required" }), {
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
let name: string | undefined;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name") as string;
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return new Response('Name is required', { status: 400 });
|
||||
const msg = "Name is required";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
}
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ name: name.trim() })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=profile');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
return redirect("/dashboard/settings?success=profile");
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return new Response('Failed to update profile', { status: 500 });
|
||||
console.error("Error updating profile:", error);
|
||||
const msg = "Failed to update profile";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
}
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user