Merge dev into fix/log-analytics-adjustments

This commit is contained in:
Fred KISSIE
2025-12-10 03:19:14 +01:00
parent 9db2feff77
commit d490cab48c
555 changed files with 9375 additions and 9287 deletions

View File

@@ -6,10 +6,7 @@ import { z } from "zod";
import { db } from "@server/db";
import { User, users } from "@server/db";
import { response } from "@server/lib/response";
import {
hashPassword,
verifyPassword
} from "@server/auth/password";
import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
@@ -23,10 +20,10 @@ import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword";
import config from "@server/lib/config";
export const changePasswordBody = z.strictObject({
oldPassword: z.string(),
newPassword: passwordSchema,
code: z.string().optional()
});
oldPassword: z.string(),
newPassword: passwordSchema,
code: z.string().optional()
});
export type ChangePasswordBody = z.infer<typeof changePasswordBody>;
@@ -62,12 +59,14 @@ async function invalidateAllSessionsExceptCurrent(
}
// Delete the user sessions (except current)
await trx.delete(sessions).where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
@@ -157,7 +156,10 @@ export async function changePassword(
.where(eq(users.userId, user.userId));
// Invalidate all sessions except the current one
await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId);
await invalidateAllSessionsExceptCurrent(
user.userId,
req.session.sessionId
);
try {
const email = user.email!;

View File

@@ -9,7 +9,7 @@ import logger from "@server/logger";
export const params = z.strictObject({
token: z.string(),
resourceId: z.string().transform(Number).pipe(z.int().positive()),
resourceId: z.string().transform(Number).pipe(z.int().positive())
});
export type CheckResourceSessionParams = z.infer<typeof params>;
@@ -21,7 +21,7 @@ export type CheckResourceSessionResponse = {
export async function checkResourceSession(
req: Request,
res: Response,
next: NextFunction,
next: NextFunction
): Promise<any> {
const parsedParams = params.safeParse(req.params);
@@ -29,8 +29,8 @@ export async function checkResourceSession(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString(),
),
fromError(parsedParams.error).toString()
)
);
}
@@ -39,7 +39,7 @@ export async function checkResourceSession(
try {
const { resourceSession } = await validateResourceSessionToken(
token,
resourceId,
resourceId
);
let valid = false;
@@ -52,15 +52,15 @@ export async function checkResourceSession(
success: true,
error: false,
message: "Checked validity",
status: HttpCode.OK,
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to reset password",
),
"Failed to reset password"
)
);
}
}

View File

@@ -17,9 +17,9 @@ import { unauthorized } from "@server/auth/unauthorizedResponse";
import { UserType } from "@server/types/UserTypes";
export const disable2faBody = z.strictObject({
password: z.string(),
code: z.string().optional()
});
password: z.string(),
code: z.string().optional()
});
export type Disable2faBody = z.infer<typeof disable2faBody>;
@@ -56,7 +56,10 @@ export async function disable2fa(
}
try {
const validPassword = await verifyPassword(password, user.passwordHash!);
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());
}

View File

@@ -16,4 +16,4 @@ export * from "./checkResourceSession";
export * from "./securityKey";
export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth";
export * from "./pollDeviceWebAuth";
export * from "./pollDeviceWebAuth";

View File

@@ -7,10 +7,7 @@ import logger from "@server/logger";
import { response } from "@server/lib/response";
import { db, deviceWebAuthCodes } from "@server/db";
import { eq, and, gt } from "drizzle-orm";
import {
createSession,
generateSessionToken
} from "@server/auth/sessions/app";
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
@@ -22,9 +19,7 @@ export type PollDeviceWebAuthParams = z.infer<typeof paramsSchema>;
// Helper function to hash device code before querying database
function hashDeviceCode(code: string): string {
return encodeHexLowerCase(
sha256(new TextEncoder().encode(code))
);
return encodeHexLowerCase(sha256(new TextEncoder().encode(code)));
}
export type PollDeviceWebAuthResponse = {
@@ -127,7 +122,9 @@ export async function pollDeviceWebAuth(
// Check if userId is set (should be set when verified)
if (!deviceCode.userId) {
logger.error("Device code is verified but userId is missing", { codeId: deviceCode.codeId });
logger.error("Device code is verified but userId is missing", {
codeId: deviceCode.codeId
});
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -165,4 +162,3 @@ export async function pollDeviceWebAuth(
);
}
}

View File

@@ -18,8 +18,8 @@ import { hashPassword } from "@server/auth/password";
import { UserType } from "@server/types/UserTypes";
export const requestPasswordResetBody = z.strictObject({
email: z.email().toLowerCase()
});
email: z.email().toLowerCase()
});
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;

View File

@@ -17,9 +17,9 @@ import { verifySession } from "@server/auth/sessions/verifySession";
import config from "@server/lib/config";
export const requestTotpSecretBody = z.strictObject({
password: z.string(),
email: z.email().optional()
});
password: z.string(),
email: z.email().optional()
});
export type RequestTotpSecretBody = z.infer<typeof requestTotpSecretBody>;
@@ -46,7 +46,8 @@ export async function requestTotpSecret(
const { password, email } = parsedBody.data;
const { user: sessionUser, session: existingSession } = await verifySession(req);
const { user: sessionUser, session: existingSession } =
await verifySession(req);
let user: User | null = sessionUser;
if (!existingSession) {
@@ -112,11 +113,7 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI(
appName,
user.email!,
hex
);
const uri = createTOTPKeyURI(appName, user.email!, hex);
await db
.update(users)

View File

@@ -18,11 +18,11 @@ import { sendEmail } from "@server/emails";
import { passwordSchema } from "@server/auth/passwordSchema";
export const resetPasswordBody = z.strictObject({
email: z.email().toLowerCase(),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
});
email: z.email().toLowerCase(),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
});
export type ResetPasswordBody = z.infer<typeof resetPasswordBody>;

View File

@@ -19,9 +19,7 @@ import type {
GenerateAuthenticationOptionsOpts,
AuthenticatorTransportFuture
} from "@simplewebauthn/server";
import {
isoBase64URL
} from '@simplewebauthn/server/helpers';
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import { verifyPassword } from "@server/auth/password";
@@ -30,10 +28,12 @@ import { verifyTotpCode } from "@server/auth/totp";
// The RP ID is the domain name of your application
const rpID = (() => {
const url = config.getRawConfig().app.dashboard_url ? new URL(config.getRawConfig().app.dashboard_url!) : undefined;
const url = config.getRawConfig().app.dashboard_url
? new URL(config.getRawConfig().app.dashboard_url!)
: undefined;
// For localhost, we must use 'localhost' without port
if (url?.hostname === 'localhost' || !url) {
return 'localhost';
if (url?.hostname === "localhost" || !url) {
return "localhost";
}
return url.hostname;
})();
@@ -46,25 +46,38 @@ const origin = config.getRawConfig().app.dashboard_url || "localhost";
// 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 security key challenges");
} catch (error) {
logger.error("Failed to clean up expired security key challenges", error);
}
}, 5 * 60 * 1000);
setInterval(
async () => {
try {
const now = Date.now();
await db
.delete(webauthnChallenge)
.where(lt(webauthnChallenge.expiresAt, now));
// logger.debug("Cleaned up expired security key challenges");
} catch (error) {
logger.error(
"Failed to clean up expired security key challenges",
error
);
}
},
5 * 60 * 1000
);
// Helper functions for challenge management
async function storeChallenge(sessionId: string, challenge: string, securityKeyName?: string, userId?: string) {
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes
async function storeChallenge(
sessionId: string,
challenge: string,
securityKeyName?: string,
userId?: string
) {
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
// Delete any existing challenge for this session
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
await db
.delete(webauthnChallenge)
.where(eq(webauthnChallenge.sessionId, sessionId));
// Insert new challenge
await db.insert(webauthnChallenge).values({
sessionId,
@@ -88,7 +101,9 @@ async function getChallenge(sessionId: string) {
// Check if expired
if (challengeData.expiresAt < Date.now()) {
await db.delete(webauthnChallenge).where(eq(webauthnChallenge.sessionId, sessionId));
await db
.delete(webauthnChallenge)
.where(eq(webauthnChallenge.sessionId, sessionId));
return null;
}
@@ -96,7 +111,9 @@ async function getChallenge(sessionId: string) {
}
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 registerSecurityKeyBody = z.strictObject({
@@ -153,7 +170,10 @@ export async function startRegistration(
try {
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());
}
@@ -197,9 +217,11 @@ export async function startRegistration(
.from(securityKeys)
.where(eq(securityKeys.userId, user.userId));
const excludeCredentials = existingSecurityKeys.map(key => ({
const excludeCredentials = existingSecurityKeys.map((key) => ({
id: key.credentialId,
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
transports: key.transports
? (JSON.parse(key.transports) as AuthenticatorTransportFuture[])
: undefined
}));
const options: GenerateRegistrationOptionsOpts = {
@@ -207,18 +229,23 @@ export async function startRegistration(
rpID,
userID: isoBase64URL.toBuffer(user.userId),
userName: user.email || user.username,
attestationType: 'none',
attestationType: "none",
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
residentKey: "preferred",
userVerification: "preferred"
}
};
const registrationOptions = await generateRegistrationOptions(options);
// Store challenge in database
await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId);
await storeChallenge(
req.session.sessionId,
registrationOptions.challenge,
name,
user.userId
);
return response<typeof registrationOptions>(res, {
data: registrationOptions,
@@ -270,7 +297,7 @@ export async function verifyRegistration(
try {
// Get challenge from database
const challengeData = await getChallenge(req.session.sessionId);
if (!challengeData) {
return next(
createHttpError(
@@ -292,10 +319,7 @@ export async function verifyRegistration(
if (!verified || !registrationInfo) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Verification failed"
)
createHttpError(HttpCode.BAD_REQUEST, "Verification failed")
);
}
@@ -303,9 +327,13 @@ export async function verifyRegistration(
await db.insert(securityKeys).values({
credentialId: registrationInfo.credential.id,
userId: user.userId,
publicKey: isoBase64URL.fromBuffer(registrationInfo.credential.publicKey),
publicKey: isoBase64URL.fromBuffer(
registrationInfo.credential.publicKey
),
signCount: registrationInfo.credential.counter || 0,
transports: registrationInfo.credential.transports ? JSON.stringify(registrationInfo.credential.transports) : null,
transports: registrationInfo.credential.transports
? JSON.stringify(registrationInfo.credential.transports)
: null,
name: challengeData.securityKeyName,
lastUsed: new Date().toISOString(),
dateCreated: new Date().toISOString()
@@ -407,7 +435,10 @@ export async function deleteSecurityKey(
try {
// Verify password
const validPassword = await verifyPassword(password, user.passwordHash!);
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(unauthorized());
}
@@ -447,10 +478,12 @@ export async function deleteSecurityKey(
await db
.delete(securityKeys)
.where(and(
eq(securityKeys.credentialId, credentialId),
eq(securityKeys.userId, user.userId)
));
.where(
and(
eq(securityKeys.credentialId, credentialId),
eq(securityKeys.userId, user.userId)
)
);
return response<null>(res, {
data: null,
@@ -502,10 +535,7 @@ export async function startAuthentication(
if (!user || user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid credentials"
)
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
);
}
@@ -525,25 +555,37 @@ export async function startAuthentication(
);
}
allowCredentials = userSecurityKeys.map(key => ({
allowCredentials = userSecurityKeys.map((key) => ({
id: key.credentialId,
transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransportFuture[] : undefined
transports: key.transports
? (JSON.parse(
key.transports
) as AuthenticatorTransportFuture[])
: undefined
}));
}
const options: GenerateAuthenticationOptionsOpts = {
rpID,
allowCredentials,
userVerification: 'preferred',
userVerification: "preferred"
};
const authenticationOptions = await generateAuthenticationOptions(options);
const authenticationOptions =
await generateAuthenticationOptions(options);
// Generate a temporary session ID for unauthenticated users
const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`;
const tempSessionId = email
? `temp_${email}_${Date.now()}`
: `temp_${Date.now()}`;
// Store challenge in database
await storeChallenge(tempSessionId, authenticationOptions.challenge, undefined, userId);
await storeChallenge(
tempSessionId,
authenticationOptions.challenge,
undefined,
userId
);
return response(res, {
data: { ...authenticationOptions, tempSessionId },
@@ -580,7 +622,7 @@ export async function verifyAuthentication(
}
const { credential } = parsedBody.data;
const tempSessionId = req.headers['x-temp-session-id'] as string;
const tempSessionId = req.headers["x-temp-session-id"] as string;
if (!tempSessionId) {
return next(
@@ -594,7 +636,7 @@ export async function verifyAuthentication(
try {
// Get challenge from database
const challengeData = await getChallenge(tempSessionId);
if (!challengeData) {
return next(
createHttpError(
@@ -646,7 +688,11 @@ export async function verifyAuthentication(
id: securityKey.credentialId,
publicKey: isoBase64URL.toBuffer(securityKey.publicKey),
counter: securityKey.signCount,
transports: securityKey.transports ? JSON.parse(securityKey.transports) as AuthenticatorTransportFuture[] : undefined
transports: securityKey.transports
? (JSON.parse(
securityKey.transports
) as AuthenticatorTransportFuture[])
: undefined
},
requireUserVerification: false
});
@@ -672,7 +718,8 @@ export async function verifyAuthentication(
.where(eq(securityKeys.credentialId, credentialId));
// 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");
const token = generateSessionToken();
const session = await createSession(token, user.userId);
const isSecure = req.protocol === "https";
@@ -703,4 +750,4 @@ export async function verifyAuthentication(
)
);
}
}
}

View File

@@ -56,8 +56,14 @@ export async function signup(
);
}
const { email, password, inviteToken, inviteId, termsAcceptedTimestamp, marketingEmailConsent } =
parsedBody.data;
const {
email,
password,
inviteToken,
inviteId,
termsAcceptedTimestamp,
marketingEmailConsent
} = parsedBody.data;
const passwordHash = await hashPassword(password);
const userId = generateId(15);
@@ -222,7 +228,9 @@ export async function signup(
);
res.appendHeader("Set-Cookie", cookie);
if (build == "saas" && marketingEmailConsent) {
logger.debug(`User ${email} opted in to marketing emails during signup.`);
logger.debug(
`User ${email} opted in to marketing emails during signup.`
);
moveEmailToAudience(email, AudienceIds.SignUps);
}

View File

@@ -13,10 +13,12 @@ import { maxmindLookup } from "@server/db/maxmind";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
const bodySchema = z.object({
deviceName: z.string().optional(),
applicationName: z.string().min(1, "Application name is required")
}).strict();
const bodySchema = z
.object({
deviceName: z.string().optional(),
applicationName: z.string().min(1, "Application name is required")
})
.strict();
export type StartDeviceWebAuthBody = z.infer<typeof bodySchema>;
@@ -34,14 +36,12 @@ function generateDeviceCode(): string {
// Helper function to hash device code before storing in database
function hashDeviceCode(code: string): string {
return encodeHexLowerCase(
sha256(new TextEncoder().encode(code))
);
return encodeHexLowerCase(sha256(new TextEncoder().encode(code)));
}
// Helper function to extract IP from request
function extractIpFromRequest(req: Request): string | undefined {
const ip = req.ip || req.socket.remoteAddress;
const ip = req.ip;
if (!ip) {
return undefined;
}
@@ -75,10 +75,10 @@ async function getCityFromIp(ip: string): Promise<string | undefined> {
return undefined;
}
// MaxMind CountryResponse doesn't include city by default
// If city data is available, it would be in result.city?.names?.en
// But since we're using CountryResponse type, we'll just return undefined
// The user said "don't do this if not easy", so we'll skip city for now
if (result.country) {
return result.country.names?.en || result.country.iso_code;
}
return undefined;
} catch (error) {
logger.debug("Failed to get city from IP", error);

View File

@@ -5,4 +5,4 @@ export type TransferSessionResponse = {
export type GetSessionTransferTokenRenponse = {
token: string;
};
};

View File

@@ -9,8 +9,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const validateSetupTokenSchema = z.strictObject({
token: z.string().min(1, "Token is required")
});
token: z.string().min(1, "Token is required")
});
export type ValidateSetupTokenResponse = {
valid: boolean;
@@ -41,10 +41,7 @@ export async function validateSetupToken(
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, token),
eq(setupTokens.used, false)
)
and(eq(setupTokens.token, token), eq(setupTokens.used, false))
);
if (!setupToken) {
@@ -79,4 +76,4 @@ export async function validateSetupToken(
)
);
}
}
}

View File

@@ -14,8 +14,8 @@ import { freeLimitSet, limitsService } from "@server/lib/billing";
import { build } from "@server/build";
export const verifyEmailBody = z.strictObject({
code: z.string()
});
code: z.string()
});
export type VerifyEmailBody = z.infer<typeof verifyEmailBody>;

View File

@@ -19,10 +19,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse";
export const verifyTotpBody = z.strictObject({
email: z.email().optional(),
password: z.string().optional(),
code: z.string()
});
email: z.email().optional(),
password: z.string().optional(),
code: z.string()
});
export type VerifyTotpBody = z.infer<typeof verifyTotpBody>;