mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-27 22:25:58 +02:00
renamed passkey to security key to stay aligned with the UI and other backend naming.
This commit is contained in:
parent
6ccc05b183
commit
5009906385
13 changed files with 158 additions and 118 deletions
|
@ -491,10 +491,24 @@ export const idpOrg = pgTable("idpOrg", {
|
||||||
orgMapping: varchar("orgMapping")
|
orgMapping: varchar("orgMapping")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const securityKeys = pgTable("webauthnCredentials", {
|
||||||
|
credentialId: varchar("credentialId").primaryKey(),
|
||||||
|
userId: varchar("userId").notNull().references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
publicKey: varchar("publicKey").notNull(),
|
||||||
|
signCount: integer("signCount").notNull(),
|
||||||
|
transports: varchar("transports"),
|
||||||
|
name: varchar("name"),
|
||||||
|
lastUsed: varchar("lastUsed").notNull(),
|
||||||
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
|
securityKeyName: varchar("securityKeyName")
|
||||||
|
});
|
||||||
|
|
||||||
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
export const webauthnChallenge = pgTable("webauthnChallenge", {
|
||||||
sessionId: varchar("sessionId").primaryKey(),
|
sessionId: varchar("sessionId").primaryKey(),
|
||||||
challenge: varchar("challenge").notNull(),
|
challenge: varchar("challenge").notNull(),
|
||||||
passkeyName: varchar("passkeyName"),
|
securityKeyName: varchar("securityKeyName"),
|
||||||
userId: varchar("userId").references(() => users.userId, {
|
userId: varchar("userId").references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -7,12 +7,37 @@ import type { Database as BetterSqlite3Database } from "better-sqlite3";
|
||||||
|
|
||||||
const migrationsFolder = path.join("server/migrations");
|
const migrationsFolder = path.join("server/migrations");
|
||||||
|
|
||||||
|
const dropAllTables = (sqlite: BetterSqlite3Database) => {
|
||||||
|
console.log("Dropping all existing tables...");
|
||||||
|
|
||||||
|
// Disable foreign key checks
|
||||||
|
sqlite.pragma('foreign_keys = OFF');
|
||||||
|
|
||||||
|
// Get all tables
|
||||||
|
const tables = sqlite.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
`).all() as { name: string }[];
|
||||||
|
|
||||||
|
// Drop each table
|
||||||
|
for (const table of tables) {
|
||||||
|
console.log(`Dropping table: ${table.name}`);
|
||||||
|
sqlite.prepare(`DROP TABLE IF EXISTS "${table.name}"`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable foreign key checks
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
};
|
||||||
|
|
||||||
const runMigrations = async () => {
|
const runMigrations = async () => {
|
||||||
console.log("Running migrations...");
|
console.log("Running migrations...");
|
||||||
try {
|
try {
|
||||||
// Initialize the database file with a valid SQLite header
|
// Initialize the database file with a valid SQLite header
|
||||||
const sqlite = new Database(location) as BetterSqlite3Database;
|
const sqlite = new Database(location) as BetterSqlite3Database;
|
||||||
sqlite.pragma('foreign_keys = ON');
|
|
||||||
|
// Drop all existing tables first
|
||||||
|
dropAllTables(sqlite);
|
||||||
|
|
||||||
// Run the migrations
|
// Run the migrations
|
||||||
migrate(db as any, {
|
migrate(db as any, {
|
||||||
|
|
|
@ -135,7 +135,7 @@ export const users = sqliteTable("user", {
|
||||||
.default(false)
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const passkeys = sqliteTable("webauthnCredentials", {
|
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||||
credentialId: text("credentialId").primaryKey(),
|
credentialId: text("credentialId").primaryKey(),
|
||||||
userId: text("userId").notNull().references(() => users.userId, {
|
userId: text("userId").notNull().references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
|
@ -151,7 +151,7 @@ export const passkeys = sqliteTable("webauthnCredentials", {
|
||||||
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||||
sessionId: text("sessionId").primaryKey(),
|
sessionId: text("sessionId").primaryKey(),
|
||||||
challenge: text("challenge").notNull(),
|
challenge: text("challenge").notNull(),
|
||||||
passkeyName: text("passkeyName"),
|
securityKeyName: text("securityKeyName"),
|
||||||
userId: text("userId").references(() => users.userId, {
|
userId: text("userId").references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -6,10 +6,10 @@ export * from "./requestTotpSecret";
|
||||||
export * from "./disable2fa";
|
export * from "./disable2fa";
|
||||||
export * from "./verifyEmail";
|
export * from "./verifyEmail";
|
||||||
export * from "./requestEmailVerificationCode";
|
export * from "./requestEmailVerificationCode";
|
||||||
export * from "./changePassword";
|
|
||||||
export * from "./requestPasswordReset";
|
|
||||||
export * from "./resetPassword";
|
export * from "./resetPassword";
|
||||||
export * from "./checkResourceSession";
|
export * from "./requestPasswordReset";
|
||||||
export * from "./setServerAdmin";
|
export * from "./setServerAdmin";
|
||||||
export * from "./initialSetupComplete";
|
export * from "./initialSetupComplete";
|
||||||
export * from "./passkey";
|
export * from "./changePassword";
|
||||||
|
export * from "./checkResourceSession";
|
||||||
|
export * from "./securityKey";
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {
|
||||||
serializeSessionCookie
|
serializeSessionCookie
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users, passkeys } from "@server/db";
|
import { users, securityKeys } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
@ -35,6 +35,7 @@ export type LoginBody = z.infer<typeof loginBodySchema>;
|
||||||
export type LoginResponse = {
|
export type LoginResponse = {
|
||||||
codeRequested?: boolean;
|
codeRequested?: boolean;
|
||||||
emailVerificationRequired?: boolean;
|
emailVerificationRequired?: boolean;
|
||||||
|
useSecurityKey?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
@ -91,15 +92,15 @@ export async function login(
|
||||||
|
|
||||||
const existingUser = existingUserRes[0];
|
const existingUser = existingUserRes[0];
|
||||||
|
|
||||||
// Check if user has passkeys registered
|
// Check if user has security keys registered
|
||||||
const userPasskeys = await db
|
const userSecurityKeys = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passkeys)
|
.from(securityKeys)
|
||||||
.where(eq(passkeys.userId, existingUser.userId));
|
.where(eq(securityKeys.userId, existingUser.userId));
|
||||||
|
|
||||||
if (userPasskeys.length > 0) {
|
if (userSecurityKeys.length > 0) {
|
||||||
return response<{ usePasskey: boolean }>(res, {
|
return response<{ useSecurityKey: boolean }>(res, {
|
||||||
data: { usePasskey: true },
|
data: { useSecurityKey: true },
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Please use your security key to sign in",
|
message: "Please use your security key to sign in",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { User, passkeys, users, webauthnChallenge } from "@server/db";
|
import { User, securityKeys, users, webauthnChallenge } from "@server/db";
|
||||||
import { eq, and, lt } from "drizzle-orm";
|
import { eq, and, lt } from "drizzle-orm";
|
||||||
import { response } from "@server/lib";
|
import { response } from "@server/lib";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
@ -55,14 +55,14 @@ setInterval(async () => {
|
||||||
await db
|
await db
|
||||||
.delete(webauthnChallenge)
|
.delete(webauthnChallenge)
|
||||||
.where(lt(webauthnChallenge.expiresAt, now));
|
.where(lt(webauthnChallenge.expiresAt, now));
|
||||||
logger.debug("Cleaned up expired passkey challenges");
|
logger.debug("Cleaned up expired security key challenges");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to clean up expired passkey challenges", error);
|
logger.error("Failed to clean up expired security key challenges", error);
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
// Helper functions for challenge management
|
// Helper functions for challenge management
|
||||||
async function storeChallenge(sessionId: string, challenge: string, passkeyName?: string, userId?: string) {
|
async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) {
|
||||||
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
|
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
|
||||||
|
|
||||||
// Delete any existing challenge for this session
|
// Delete any existing challenge for this session
|
||||||
|
@ -72,7 +72,7 @@ async function storeChallenge(sessionId: string, challenge: string, passkeyName?
|
||||||
await db.insert(webauthnChallenge).values({
|
await db.insert(webauthnChallenge).values({
|
||||||
sessionId,
|
sessionId,
|
||||||
challenge,
|
challenge,
|
||||||
passkeyName,
|
securityKeyName,
|
||||||
userId,
|
userId,
|
||||||
expiresAt
|
expiresAt
|
||||||
});
|
});
|
||||||
|
@ -102,7 +102,7 @@ async function clearChallenge(sessionId: string) {
|
||||||
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerPasskeyBody = z.object({
|
export const registerSecurityKeyBody = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
password: z.string().min(1)
|
password: z.string().min(1)
|
||||||
}).strict();
|
}).strict();
|
||||||
|
@ -119,7 +119,7 @@ export const verifyAuthenticationBody = z.object({
|
||||||
credential: z.any()
|
credential: z.any()
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const deletePasskeyBody = z.object({
|
export const deleteSecurityKeyBody = z.object({
|
||||||
password: z.string().min(1)
|
password: z.string().min(1)
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
@ -128,7 +128,7 @@ export async function startRegistration(
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const parsedBody = registerPasskeyBody.safeParse(req.body);
|
const parsedBody = registerSecurityKeyBody.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -142,12 +142,12 @@ export async function startRegistration(
|
||||||
const { name, password } = parsedBody.data;
|
const { name, password } = parsedBody.data;
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use security keys
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Passkeys are only available for internal users"
|
"Security keys are only available for internal users"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -170,13 +170,13 @@ export async function startRegistration(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing passkeys for user
|
// Get existing security keys for user
|
||||||
const existingPasskeys = await db
|
const existingSecurityKeys = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passkeys)
|
.from(securityKeys)
|
||||||
.where(eq(passkeys.userId, user.userId));
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
const excludeCredentials = existingPasskeys.map(key => ({
|
const excludeCredentials = existingSecurityKeys.map(key => ({
|
||||||
id: Buffer.from(key.credentialId, 'base64').toString('base64url'),
|
id: Buffer.from(key.credentialId, 'base64').toString('base64url'),
|
||||||
type: 'public-key' as const,
|
type: 'public-key' as const,
|
||||||
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
||||||
|
@ -237,12 +237,12 @@ export async function verifyRegistration(
|
||||||
const { credential } = parsedBody.data;
|
const { credential } = parsedBody.data;
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use security keys
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Passkeys are only available for internal users"
|
"Security keys are only available for internal users"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -279,14 +279,14 @@ export async function verifyRegistration(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the passkey in the database
|
// Store the security key in the database
|
||||||
await db.insert(passkeys).values({
|
await db.insert(securityKeys).values({
|
||||||
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
credentialId: Buffer.from(registrationInfo.credentialID).toString('base64'),
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
publicKey: Buffer.from(registrationInfo.credentialPublicKey).toString('base64'),
|
||||||
signCount: registrationInfo.counter || 0,
|
signCount: registrationInfo.counter || 0,
|
||||||
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
transports: credential.response.transports ? JSON.stringify(credential.response.transports) : null,
|
||||||
name: challengeData.passkeyName,
|
name: challengeData.securityKeyName,
|
||||||
lastUsed: new Date().toISOString(),
|
lastUsed: new Date().toISOString(),
|
||||||
dateCreated: new Date().toISOString()
|
dateCreated: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
@ -298,7 +298,7 @@ export async function verifyRegistration(
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Passkey registered successfully",
|
message: "Security key registered successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -312,34 +312,34 @@ export async function verifyRegistration(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPasskeys(
|
export async function listSecurityKeys(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use security keys
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Passkeys are only available for internal users"
|
"Security keys are only available for internal users"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userPasskeys = await db
|
const userSecurityKeys = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passkeys)
|
.from(securityKeys)
|
||||||
.where(eq(passkeys.userId, user.userId));
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
return response<typeof userPasskeys>(res, {
|
return response<typeof userSecurityKeys>(res, {
|
||||||
data: userPasskeys,
|
data: userSecurityKeys,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Passkeys retrieved successfully",
|
message: "Security keys retrieved successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -347,13 +347,13 @@ export async function listPasskeys(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to retrieve passkeys"
|
"Failed to retrieve security keys"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePasskey(
|
export async function deleteSecurityKey(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
|
@ -362,7 +362,7 @@ export async function deletePasskey(
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
const parsedBody = deletePasskeyBody.safeParse(req.body);
|
const parsedBody = deleteSecurityKeyBody.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -375,12 +375,12 @@ export async function deletePasskey(
|
||||||
|
|
||||||
const { password } = parsedBody.data;
|
const { password } = parsedBody.data;
|
||||||
|
|
||||||
// Only allow internal users to use passkeys
|
// Only allow internal users to use security keys
|
||||||
if (user.type !== UserType.Internal) {
|
if (user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Passkeys are only available for internal users"
|
"Security keys are only available for internal users"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -404,17 +404,17 @@ export async function deletePasskey(
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(passkeys)
|
.delete(securityKeys)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(passkeys.credentialId, credentialId),
|
eq(securityKeys.credentialId, credentialId),
|
||||||
eq(passkeys.userId, user.userId)
|
eq(securityKeys.userId, user.userId)
|
||||||
));
|
));
|
||||||
|
|
||||||
return response<null>(res, {
|
return response<null>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Passkey deleted successfully",
|
message: "Security key deleted successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -422,7 +422,7 @@ export async function deletePasskey(
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to delete passkey"
|
"Failed to delete security key"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -454,7 +454,7 @@ export async function startAuthentication(
|
||||||
}> = [];
|
}> = [];
|
||||||
let userId;
|
let userId;
|
||||||
|
|
||||||
// If email is provided, get passkeys for that specific user
|
// If email is provided, get security keys for that specific user
|
||||||
if (email) {
|
if (email) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -473,27 +473,27 @@ export async function startAuthentication(
|
||||||
|
|
||||||
userId = user.userId;
|
userId = user.userId;
|
||||||
|
|
||||||
const userPasskeys = await db
|
const userSecurityKeys = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passkeys)
|
.from(securityKeys)
|
||||||
.where(eq(passkeys.userId, user.userId));
|
.where(eq(securityKeys.userId, user.userId));
|
||||||
|
|
||||||
if (userPasskeys.length === 0) {
|
if (userSecurityKeys.length === 0) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"No passkeys registered for this user"
|
"No security keys registered for this user"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allowCredentials = userPasskeys.map(key => ({
|
allowCredentials = userSecurityKeys.map(key => ({
|
||||||
id: Buffer.from(key.credentialId, 'base64'),
|
id: Buffer.from(key.credentialId, 'base64'),
|
||||||
type: 'public-key' as const,
|
type: 'public-key' as const,
|
||||||
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// If no email provided, allow any passkey (for resident key authentication)
|
// If no email provided, allow any security key (for resident key authentication)
|
||||||
allowCredentials = [];
|
allowCredentials = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -570,15 +570,15 @@ export async function verifyAuthentication(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the passkey in database
|
// Find the security key in database
|
||||||
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
const credentialId = Buffer.from(credential.id, 'base64').toString('base64');
|
||||||
const [passkey] = await db
|
const [securityKey] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(passkeys)
|
.from(securityKeys)
|
||||||
.where(eq(passkeys.credentialId, credentialId))
|
.where(eq(securityKeys.credentialId, credentialId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!passkey) {
|
if (!securityKey) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
@ -591,14 +591,14 @@ export async function verifyAuthentication(
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.userId, passkey.userId))
|
.where(eq(users.userId, securityKey.userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user || user.type !== UserType.Internal) {
|
if (!user || user.type !== UserType.Internal) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"User not found or not authorized for passkey authentication"
|
"User not found or not authorized for security key authentication"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -609,10 +609,10 @@ export async function verifyAuthentication(
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: rpID,
|
expectedRPID: rpID,
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: Buffer.from(passkey.credentialId, 'base64'),
|
credentialID: Buffer.from(securityKey.credentialId, 'base64'),
|
||||||
credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'),
|
credentialPublicKey: Buffer.from(securityKey.publicKey, 'base64'),
|
||||||
counter: passkey.signCount,
|
counter: securityKey.signCount,
|
||||||
transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined
|
transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransport[] : undefined
|
||||||
},
|
},
|
||||||
requireUserVerification: false
|
requireUserVerification: false
|
||||||
});
|
});
|
||||||
|
@ -630,12 +630,12 @@ export async function verifyAuthentication(
|
||||||
|
|
||||||
// Update sign count
|
// Update sign count
|
||||||
await db
|
await db
|
||||||
.update(passkeys)
|
.update(securityKeys)
|
||||||
.set({
|
.set({
|
||||||
signCount: authenticationInfo.newCounter,
|
signCount: authenticationInfo.newCounter,
|
||||||
lastUsed: new Date().toISOString()
|
lastUsed: new Date().toISOString()
|
||||||
})
|
})
|
||||||
.where(eq(passkeys.credentialId, credentialId));
|
.where(eq(securityKeys.credentialId, credentialId));
|
||||||
|
|
||||||
// Create session for the user
|
// Create session for the user
|
||||||
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app");
|
|
@ -789,35 +789,35 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
|
||||||
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
authRouter.put("/set-server-admin", auth.setServerAdmin);
|
||||||
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
|
||||||
|
|
||||||
// Passkey routes
|
// Security Key routes
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/passkey/register/start",
|
"/security-key/register/start",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 5, // Allow 5 passkey registrations per 15 minutes per IP
|
max: 5, // Allow 5 security key registrations per 15 minutes per IP
|
||||||
keyGenerator: (req) => `passkeyRegister:${req.ip}:${req.user?.userId}`,
|
keyGenerator: (req) => `securityKeyRegister:${req.ip}:${req.user?.userId}`,
|
||||||
handler: (req, res, next) => {
|
handler: (req, res, next) => {
|
||||||
const message = `You can only register ${5} passkeys every ${15} minutes. Please try again later.`;
|
const message = `You can only register ${5} security keys every ${15} minutes. Please try again later.`;
|
||||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
verifySessionUserMiddleware,
|
verifySessionUserMiddleware,
|
||||||
auth.startRegistration
|
auth.startRegistration
|
||||||
);
|
);
|
||||||
authRouter.post("/passkey/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
authRouter.post("/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration);
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/passkey/authenticate/start",
|
"/security-key/authenticate/start",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
|
||||||
keyGenerator: (req) => `passkeyAuth:${req.ip}`,
|
keyGenerator: (req) => `securityKeyAuth:${req.ip}`,
|
||||||
handler: (req, res, next) => {
|
handler: (req, res, next) => {
|
||||||
const message = `You can only attempt passkey authentication ${10} times every ${15} minutes. Please try again later.`;
|
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
|
||||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
auth.startAuthentication
|
auth.startAuthentication
|
||||||
);
|
);
|
||||||
authRouter.post("/passkey/authenticate/verify", auth.verifyAuthentication);
|
authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication);
|
||||||
authRouter.get("/passkey/list", verifySessionUserMiddleware, auth.listPasskeys);
|
authRouter.get("/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys);
|
||||||
authRouter.delete("/passkey/:credentialId", verifySessionUserMiddleware, auth.deletePasskey);
|
authRouter.delete("/security-key/:credentialId", verifySessionUserMiddleware, auth.deleteSecurityKey);
|
||||||
|
|
|
@ -8,7 +8,7 @@ export default async function migration() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.transaction((trx) => {
|
db.transaction((trx) => {
|
||||||
trx.run(sql`CREATE TABLE 'passkey' (
|
trx.run(sql`CREATE TABLE 'securityKey' (
|
||||||
'credentialId' text PRIMARY KEY NOT NULL,
|
'credentialId' text PRIMARY KEY NOT NULL,
|
||||||
'userId' text NOT NULL,
|
'userId' text NOT NULL,
|
||||||
'publicKey' text NOT NULL,
|
'publicKey' text NOT NULL,
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default async function migration() {
|
||||||
db.pragma("foreign_keys = OFF");
|
db.pragma("foreign_keys = OFF");
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS passkey (
|
CREATE TABLE IF NOT EXISTS securityKey (
|
||||||
credentialId TEXT PRIMARY KEY,
|
credentialId TEXT PRIMARY KEY,
|
||||||
userId TEXT NOT NULL,
|
userId TEXT NOT NULL,
|
||||||
publicKey TEXT NOT NULL,
|
publicKey TEXT NOT NULL,
|
||||||
|
@ -28,9 +28,9 @@ export default async function migration() {
|
||||||
`);
|
`);
|
||||||
})(); // executes the transaction immediately
|
})(); // executes the transaction immediately
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
console.log(`Created passkey table`);
|
console.log(`Created securityKey table`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Unable to create passkey table");
|
console.error("Unable to create securityKey table");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,22 +14,22 @@ export default async function migration() {
|
||||||
db.pragma("foreign_keys = OFF");
|
db.pragma("foreign_keys = OFF");
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS passkeyChallenge (
|
CREATE TABLE IF NOT EXISTS securityKeyChallenge (
|
||||||
sessionId TEXT PRIMARY KEY,
|
sessionId TEXT PRIMARY KEY,
|
||||||
challenge TEXT NOT NULL,
|
challenge TEXT NOT NULL,
|
||||||
passkeyName TEXT,
|
securityKeyName TEXT,
|
||||||
userId TEXT,
|
userId TEXT,
|
||||||
expiresAt INTEGER NOT NULL,
|
expiresAt INTEGER NOT NULL,
|
||||||
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_passkeyChallenge_expiresAt ON passkeyChallenge(expiresAt);
|
CREATE INDEX IF NOT EXISTS idx_securityKeyChallenge_expiresAt ON securityKeyChallenge(expiresAt);
|
||||||
`);
|
`);
|
||||||
})(); // executes the transaction immediately
|
})(); // executes the transaction immediately
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
console.log(`Created passkeyChallenge table`);
|
console.log(`Created securityKeyChallenge table`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Unable to create passkeyChallenge table");
|
console.error("Unable to create securityKeyChallenge table");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,21 +6,21 @@ export default async function migrate() {
|
||||||
|
|
||||||
// Rename the table
|
// Rename the table
|
||||||
await db.run(`
|
await db.run(`
|
||||||
ALTER TABLE passkeyChallenge RENAME TO webauthnChallenge;
|
ALTER TABLE securityKeyChallenge RENAME TO webauthnChallenge;
|
||||||
`);
|
`);
|
||||||
console.log("Successfully renamed table");
|
console.log("Successfully renamed table");
|
||||||
|
|
||||||
// Rename the index
|
// Rename the index
|
||||||
await db.run(`
|
await db.run(`
|
||||||
DROP INDEX IF EXISTS idx_passkeyChallenge_expiresAt;
|
DROP INDEX IF EXISTS idx_securityKeyChallenge_expiresAt;
|
||||||
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
|
CREATE INDEX IF NOT EXISTS idx_webauthnChallenge_expiresAt ON webauthnChallenge(expiresAt);
|
||||||
`);
|
`);
|
||||||
console.log("Successfully updated index");
|
console.log("Successfully updated index");
|
||||||
|
|
||||||
console.log(`Renamed passkeyChallenge table to webauthnChallenge`);
|
console.log(`Renamed securityKeyChallenge table to webauthnChallenge`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Unable to rename passkeyChallenge table:", error);
|
console.error("Unable to rename securityKeyChallenge table:", error);
|
||||||
console.error("Error details:", error.message);
|
console.error("Error details:", error.message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
@ -13,15 +13,15 @@ import {
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from "@/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { LoginResponse } from "@server/routers/auth";
|
import { LoginResponse } from "@server/routers/auth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -120,8 +120,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
if (data?.usePasskey) {
|
if (data?.useSecurityKey) {
|
||||||
await initiateSecurityKeyAuth();
|
setShowSecurityKeyPrompt(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,7 +197,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
const email = form.getValues().email;
|
const email = form.getValues().email;
|
||||||
|
|
||||||
// Start WebAuthn authentication
|
// Start WebAuthn authentication
|
||||||
const startRes = await api.post("/auth/passkey/authenticate/start", {
|
const startRes = await api.post("/auth/security-key/authenticate/start", {
|
||||||
email: email || undefined
|
email: email || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -216,7 +216,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
|
|
||||||
// Verify authentication
|
// Verify authentication
|
||||||
const verifyRes = await api.post(
|
const verifyRes = await api.post(
|
||||||
"/auth/passkey/authenticate/verify",
|
"/auth/security-key/authenticate/verify",
|
||||||
{ credential },
|
{ credential },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -355,9 +355,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
pattern={
|
pattern={
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(value: string) => {
|
||||||
field.onChange(e);
|
field.onChange(value);
|
||||||
if (e.length === 6) {
|
if (value.length === 6) {
|
||||||
mfaForm.handleSubmit(onSubmit)();
|
mfaForm.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -108,7 +108,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
|
|
||||||
const loadSecurityKeys = async () => {
|
const loadSecurityKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get("/auth/passkey/list");
|
const response = await api.get("/auth/security-key/list");
|
||||||
setSecurityKeys(response.data.data);
|
setSecurityKeys(response.data.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
|
@ -132,7 +132,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
const startRes = await api.post("/auth/passkey/register/start", {
|
const startRes = await api.post("/auth/security-key/register/start", {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
});
|
});
|
||||||
|
@ -152,7 +152,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
try {
|
try {
|
||||||
const credential = await startRegistration(options);
|
const credential = await startRegistration(options);
|
||||||
|
|
||||||
await api.post("/auth/passkey/register/verify", {
|
await api.post("/auth/security-key/register/verify", {
|
||||||
credential,
|
credential,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -217,7 +217,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
|
const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId);
|
||||||
await api.delete(`/auth/passkey/${encodedCredentialId}`, {
|
await api.delete(`/auth/security-key/${encodedCredentialId}`, {
|
||||||
data: {
|
data: {
|
||||||
password: values.password,
|
password: values.password,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue