mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 02:46:37 +00:00
refactor: rename passkeyChallenge to webauthnChallenge
- Renamed table for consistency with webauthnCredentials - Created migration script 1.8.1.ts for table rename - Updated schema definitions in SQLite and PostgreSQL - Maintains WebAuthn standard naming convention
This commit is contained in:
@@ -491,6 +491,16 @@ export const idpOrg = pgTable("idpOrg", {
|
||||
orgMapping: varchar("orgMapping")
|
||||
});
|
||||
|
||||
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||
sessionId: varchar("sessionId").primaryKey(),
|
||||
challenge: varchar("challenge").notNull(),
|
||||
passkeyName: varchar("passkeyName"),
|
||||
userId: varchar("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
||||
@@ -13,6 +13,8 @@ bootstrapVolume();
|
||||
|
||||
function createDb() {
|
||||
const sqlite = new Database(location);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.exec('VACUUM;'); // This will initialize the database file with a valid SQLite header
|
||||
return DrizzleSqlite(sqlite, { schema });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import db from "./driver";
|
||||
import path from "path";
|
||||
import { location } from "./driver";
|
||||
import Database from "better-sqlite3";
|
||||
import type { Database as BetterSqlite3Database } from "better-sqlite3";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
// Initialize the database file with a valid SQLite header
|
||||
const sqlite = new Database(location) as BetterSqlite3Database;
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Run the migrations
|
||||
migrate(db as any, {
|
||||
migrationsFolder: migrationsFolder,
|
||||
});
|
||||
|
||||
@@ -135,6 +135,29 @@ export const users = sqliteTable("user", {
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const passkeys = sqliteTable("webauthnCredentials", {
|
||||
credentialId: text("credentialId").primaryKey(),
|
||||
userId: text("userId").notNull().references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
signCount: integer("signCount").notNull(),
|
||||
transports: text("transports"),
|
||||
name: text("name"),
|
||||
lastUsed: text("lastUsed").notNull(),
|
||||
dateCreated: text("dateCreated").notNull()
|
||||
});
|
||||
|
||||
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||
sessionId: text("sessionId").primaryKey(),
|
||||
challenge: text("challenge").notNull(),
|
||||
passkeyName: text("passkeyName"),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
expiresAt: integer("expiresAt").notNull() // Unix timestamp
|
||||
});
|
||||
|
||||
export const newts = sqliteTable("newt", {
|
||||
newtId: text("id").primaryKey(),
|
||||
secretHash: text("secretHash").notNull(),
|
||||
|
||||
@@ -35,7 +35,7 @@ declare global {
|
||||
interface Request {
|
||||
apiKey?: ApiKey;
|
||||
user?: User;
|
||||
session?: Session;
|
||||
session: Session;
|
||||
userOrg?: UserOrg;
|
||||
apiKeyOrg?: ApiKeyOrg;
|
||||
userOrgRoleId?: number;
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./resetPassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./setServerAdmin";
|
||||
export * from "./initialSetupComplete";
|
||||
export * from "./passkey";
|
||||
|
||||
606
server/routers/auth/passkey.ts
Normal file
606
server/routers/auth/passkey.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { User, passkeys, users, webauthnChallenge } from "@server/db";
|
||||
import { eq, and, lt } from "drizzle-orm";
|
||||
import { response } from "@server/lib";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifiedAuthenticationResponse
|
||||
} from "@simplewebauthn/server";
|
||||
import config from "@server/lib/config";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
|
||||
// The RP ID is the domain name of your application
|
||||
const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname;
|
||||
const rpName = "Pangolin";
|
||||
const origin = config.getRawConfig().app.dashboard_url;
|
||||
|
||||
// Database-based challenge storage (replaces in-memory storage)
|
||||
// Challenges are stored in the webauthnChallenge table with automatic expiration
|
||||
// This supports clustered deployments and persists across server restarts
|
||||
|
||||
// Clean up expired challenges every 5 minutes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await db
|
||||
.delete(webauthnChallenge)
|
||||
.where(lt(webauthnChallenge.expiresAt, now));
|
||||
logger.debug("Cleaned up expired passkey challenges");
|
||||
} catch (error) {
|
||||
logger.error("Failed to clean up expired passkey challenges", error);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Helper functions for challenge management
|
||||
async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) {
|
||||
const expiresAt = Date.now() + (10 * 60 * 1000); // 10 minutes
|
||||
|
||||
// Delete any existing challenge for this session
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
|
||||
// Insert new challenge
|
||||
await db.insert(webauthnChallenge).values({
|
||||
sessionId,
|
||||
challenge,
|
||||
passkeyName,
|
||||
userId,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
async function getChallenge(sessionId: string) {
|
||||
const [challengeData] = await db
|
||||
.select()
|
||||
.from(webauthnChallenge)
|
||||
.where(eq(webauthnChallenge.sessionId, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (!challengeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (challengeData.expiresAt < Date.now()) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
return null;
|
||||
}
|
||||
|
||||
return challengeData;
|
||||
}
|
||||
|
||||
async function clearChallenge(sessionId: string) {
|
||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||
}
|
||||
|
||||
export const registerPasskeyBody = z.object({
|
||||
name: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
export const verifyRegistrationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export const startAuthenticationBody = z.object({
|
||||
email: z.string().email().optional()
|
||||
}).strict();
|
||||
|
||||
export const verifyAuthenticationBody = z.object({
|
||||
credential: z.any()
|
||||
}).strict();
|
||||
|
||||
export async function startRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = registerPasskeyBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get existing passkeys for user
|
||||
const existingPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
const excludeCredentials = existingPasskeys.map(key => ({
|
||||
id: Buffer.from(key.credentialId, 'base64'),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
||||
}));
|
||||
|
||||
const options: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID,
|
||||
userID: user.userId,
|
||||
userName: user.email || user.username,
|
||||
attestationType: 'none',
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
}
|
||||
};
|
||||
|
||||
const registrationOptions = await generateRegistrationOptions(options);
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
|
||||
|
||||
return response(res, {
|
||||
data: registrationOptions,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Registration options generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate registration options"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyRegistrationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(req.session.sessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No challenge found in session or challenge expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
if (!verified || !registrationInfo) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Verification failed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Store the passkey in the database
|
||||
await db.insert(passkeys).values({
|
||||
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
||||
userId: user.userId,
|
||||
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
||||
signCount: registrationInfo.counter || 0,
|
||||
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
||||
name: challengeData.passkeyName,
|
||||
lastUsed: new Date().toISOString(),
|
||||
dateCreated: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(req.session.sessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkey registered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify registration"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPasskeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const userPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
return response<typeof userPasskeys>(res, {
|
||||
data: userPasskeys,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkeys retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve passkeys"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePasskey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const { credentialId: encodedCredentialId } = req.params;
|
||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||
const user = req.user as User;
|
||||
|
||||
// Only allow internal users to use passkeys
|
||||
if (user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkeys are only available for internal users"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(passkeys)
|
||||
.where(and(
|
||||
eq(passkeys.credentialId, credentialId),
|
||||
eq(passkeys.userId, user.userId)
|
||||
));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Passkey deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete passkey"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = startAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsedBody.data;
|
||||
|
||||
try {
|
||||
let allowCredentials: Array<{
|
||||
id: Buffer;
|
||||
type: 'public-key';
|
||||
transports?: string[];
|
||||
}> = [];
|
||||
let userId;
|
||||
|
||||
// If email is provided, get passkeys for that specific user
|
||||
if (email) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No passkeys available for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
userId = user.userId;
|
||||
|
||||
const userPasskeys = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.userId));
|
||||
|
||||
if (userPasskeys.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No passkeys registered for this user"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allowCredentials = userPasskeys.map(key => ({
|
||||
id: Buffer.from(key.credentialId, 'base64'),
|
||||
type: 'public-key' as const,
|
||||
transports: key.transports ? JSON.parse(key.transports) : undefined
|
||||
}));
|
||||
} else {
|
||||
// If no email provided, allow any passkey (for resident key authentication)
|
||||
allowCredentials = [];
|
||||
}
|
||||
|
||||
const options: GenerateAuthenticationOptionsOpts = {
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
};
|
||||
|
||||
const authenticationOptions = await generateAuthenticationOptions(options);
|
||||
|
||||
// Generate a temporary session ID for unauthenticated users
|
||||
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
|
||||
|
||||
// Store challenge in database
|
||||
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
|
||||
|
||||
return response(res, {
|
||||
data: { ...authenticationOptions, tempSessionId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication options generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to generate authentication options"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAuthentication(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = verifyAuthenticationBody.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { credential } = parsedBody.data;
|
||||
const tempSessionId = req.headers['x-temp-session-id'] as string;
|
||||
|
||||
if (!tempSessionId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Missing temp session ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get challenge from database
|
||||
const challengeData = await getChallenge(tempSessionId);
|
||||
|
||||
if (!challengeData) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"No challenge found or challenge expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Find the passkey in database
|
||||
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
||||
const [passkey] = await db
|
||||
.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (!passkey) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Passkey not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, passkey.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.type !== UserType.Internal) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User not found or not authorized for passkey authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credential,
|
||||
expectedChallenge: challengeData.challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(passkey.credentialId, 'base64'),
|
||||
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
|
||||
counter: passkey.signCount,
|
||||
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined
|
||||
},
|
||||
requireUserVerification: false
|
||||
});
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (!verified) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Authentication failed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Update sign count
|
||||
await db
|
||||
.update(passkeys)
|
||||
.set({
|
||||
signCount: authenticationInfo.newCounter,
|
||||
lastUsed: new Date().toISOString()
|
||||
})
|
||||
.where(eq(passkeys.credentialId, credentialId));
|
||||
|
||||
// Create session for the user
|
||||
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
||||
const token = generateSessionToken();
|
||||
const session = await createSession(token, user.userId);
|
||||
const isSecure = req.protocol === "https";
|
||||
const cookie = serializeSessionCookie(
|
||||
token,
|
||||
isSecure,
|
||||
new Date(session.expiresAt)
|
||||
);
|
||||
|
||||
res.setHeader("Set-Cookie", cookie);
|
||||
|
||||
// Clear challenge data
|
||||
await clearChallenge(tempSessionId);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Authentication successful",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify authentication"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -788,3 +788,36 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||
|
||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||
|
||||
// Passkey routes
|
||||
authRouter.post(
|
||||
"/passkey/register/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Allow 5 passkey registrations per 15 minutes per IP
|
||||
keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
verifySessionUserMiddleware,
|
||||
auth.startRegistration
|
||||
);
|
||||
authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
||||
authRouter.post(
|
||||
"/passkey/authenticate/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||
keyGenerator: (req) => `passkeyAuth:${req.ip}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
}
|
||||
}),
|
||||
auth.startAuthentication
|
||||
);
|
||||
authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication);
|
||||
authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys);
|
||||
authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey);
|
||||
|
||||
@@ -22,6 +22,8 @@ import m18 from "./scriptsSqlite/1.2.0";
|
||||
import m19 from "./scriptsSqlite/1.3.0";
|
||||
import m20 from "./scriptsSqlite/1.5.0";
|
||||
import m21 from "./scriptsSqlite/1.6.0";
|
||||
import m22 from "./scriptsSqlite/1.7.0";
|
||||
import m23 from "./scriptsSqlite/1.8.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -43,7 +45,9 @@ const migrations = [
|
||||
{ version: "1.2.0", run: m18 },
|
||||
{ version: "1.3.0", run: m19 },
|
||||
{ version: "1.5.0", run: m20 },
|
||||
{ version: "1.6.0", run: m21 }
|
||||
{ version: "1.6.0", run: m21 },
|
||||
{ version: "1.7.0", run: m22 },
|
||||
{ version: "1.8.0", run: m23 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
@@ -79,17 +83,21 @@ export async function runMigrations() {
|
||||
try {
|
||||
const appVersion = APP_VERSION;
|
||||
|
||||
if (exists) {
|
||||
// Check if the database file exists and has tables
|
||||
const hasTables = await db.select().from(versionMigrations).limit(1).catch(() => false);
|
||||
|
||||
if (hasTables) {
|
||||
await executeScripts();
|
||||
} else {
|
||||
console.log("Running migrations...");
|
||||
console.log("Running initial migrations...");
|
||||
try {
|
||||
migrate(db, {
|
||||
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
|
||||
migrationsFolder: path.join(APP_PATH, "server", "migrations")
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
console.log("Initial migrations completed successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error running migrations:", error);
|
||||
console.error("Error running initial migrations:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db
|
||||
|
||||
31
server/setup/scriptsSqlite/1.4.0.ts
Normal file
31
server/setup/scriptsSqlite/1.4.0.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { db } from "../../db/sqlite";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.4.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`CREATE TABLE 'passkey' (
|
||||
'credentialId' text PRIMARY KEY NOT NULL,
|
||||
'userId' text NOT NULL,
|
||||
'publicKey' text NOT NULL,
|
||||
'signCount' integer NOT NULL,
|
||||
'transports' text,
|
||||
'name' text,
|
||||
'lastUsed' text NOT NULL,
|
||||
'dateCreated' text NOT NULL,
|
||||
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON DELETE CASCADE
|
||||
);`);
|
||||
});
|
||||
|
||||
console.log(`Migrated database schema`);
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate database schema");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
39
server/setup/scriptsSqlite/1.7.0.ts
Normal file
39
server/setup/scriptsSqlite/1.7.0.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.7.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS passkey (
|
||||
credentialId TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
publicKey TEXT NOT NULL,
|
||||
signCount INTEGER NOT NULL,
|
||||
transports TEXT,
|
||||
name TEXT,
|
||||
lastUsed TEXT NOT NULL,
|
||||
dateCreated TEXT NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
})(); // executes the transaction immediately
|
||||
db.pragma("foreign_keys = ON");
|
||||
console.log(`Created passkey table`);
|
||||
} catch (e) {
|
||||
console.error("Unable to create passkey table");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
38
server/setup/scriptsSqlite/1.8.0.ts
Normal file
38
server/setup/scriptsSqlite/1.8.0.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.8.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS passkeyChallenge (
|
||||
sessionId TEXT PRIMARY KEY,
|
||||
challenge TEXT NOT NULL,
|
||||
passkeyName TEXT,
|
||||
userId TEXT,
|
||||
expiresAt INTEGER NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt);
|
||||
`);
|
||||
})(); // executes the transaction immediately
|
||||
db.pragma("foreign_keys = ON");
|
||||
console.log(`Created passkeyChallenge table`);
|
||||
} catch (e) {
|
||||
console.error("Unable to create passkeyChallenge table");
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
27
server/setup/scriptsSqlite/1.8.1.ts
Normal file
27
server/setup/scriptsSqlite/1.8.1.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { db } from "@server/db";
|
||||
|
||||
export default async function migrate() {
|
||||
try {
|
||||
console.log("Starting table rename migration...");
|
||||
|
||||
// Rename the table
|
||||
await db.run(`
|
||||
ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge;
|
||||
`);
|
||||
console.log("Successfully renamed table");
|
||||
|
||||
// Rename the index
|
||||
await db.run(`
|
||||
DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt;
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
|
||||
`);
|
||||
console.log("Successfully updated index");
|
||||
|
||||
console.log(`Renamed passkeyChallenge table to webauthnChallenge`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error("Unable to rename passkeyChallenge table:", error);
|
||||
console.error("Error details:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user