hopeful
This commit is contained in:
@@ -9,6 +9,7 @@ import { blogRouter } from "./routers/blog";
|
||||
import { gitActivityRouter } from "./routers/git-activity";
|
||||
import { postHistoryRouter } from "./routers/post-history";
|
||||
import { infillRouter } from "./routers/infill";
|
||||
import { accountRouter } from "./routers/account";
|
||||
import { createTRPCRouter, createTRPCContext } from "./utils";
|
||||
import type { H3Event } from "h3";
|
||||
|
||||
@@ -23,7 +24,8 @@ export const appRouter = createTRPCRouter({
|
||||
blog: blogRouter,
|
||||
gitActivity: gitActivityRouter,
|
||||
postHistory: postHistoryRouter,
|
||||
infill: infillRouter
|
||||
infill: infillRouter,
|
||||
account: accountRouter
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
255
src/server/api/routers/account.ts
Normal file
255
src/server/api/routers/account.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
getUserProviders,
|
||||
unlinkProvider,
|
||||
getProviderSummary
|
||||
} from "~/server/provider-helpers";
|
||||
import {
|
||||
getUserActiveSessions,
|
||||
revokeUserSession,
|
||||
revokeOtherUserSessions,
|
||||
getSessionCountByDevice
|
||||
} from "~/server/session-management";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getAuditContext } from "~/server/security";
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import type { Context } from "../utils";
|
||||
|
||||
/**
|
||||
* Extract H3Event from Context
|
||||
*/
|
||||
function getH3Event(ctx: Context): H3Event {
|
||||
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||
return ctx.event.nativeEvent as H3Event;
|
||||
}
|
||||
return ctx.event as unknown as H3Event;
|
||||
}
|
||||
|
||||
export const accountRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get all linked authentication providers for current user
|
||||
*/
|
||||
getLinkedProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const summary = await getProviderSummary(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
providers: summary.providers,
|
||||
count: summary.count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching linked providers:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch linked providers"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Unlink an authentication provider
|
||||
*/
|
||||
unlinkProvider: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
provider: z.enum(["email", "google", "github"])
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const { provider } = input;
|
||||
|
||||
await unlinkProvider(userId, provider);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.unlinked",
|
||||
eventData: { provider },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${provider} authentication unlinked successfully`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error unlinking provider:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to unlink provider"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get all active sessions for current user
|
||||
*/
|
||||
getActiveSessions: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const sessions = await getUserActiveSessions(userId);
|
||||
|
||||
// Mark current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
const currentSessionId = currentSession?.sessionId;
|
||||
|
||||
const sessionsWithCurrent = sessions.map((session) => ({
|
||||
...session,
|
||||
current: session.sessionId === currentSessionId
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessions: sessionsWithCurrent
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching active sessions:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch active sessions"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get session statistics by device type
|
||||
*/
|
||||
getSessionStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const stats = await getSessionCountByDevice(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching session stats:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch session stats"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
revokeSession: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
const { sessionId } = input;
|
||||
|
||||
await revokeUserSession(userId, sessionId);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_revoked",
|
||||
eventData: { sessionId, reason: "user_request" },
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Session revoked successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking session:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke session"
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Revoke all other sessions (keep current session active)
|
||||
*/
|
||||
revokeOtherSessions: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
const userId = ctx.userId!;
|
||||
|
||||
// Get current session
|
||||
const currentSession = await getAuthSession(getH3Event(ctx));
|
||||
if (!currentSession) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No active session found"
|
||||
});
|
||||
}
|
||||
|
||||
const revokedCount = await revokeOtherUserSessions(
|
||||
userId,
|
||||
currentSession.sessionId
|
||||
);
|
||||
|
||||
// Log audit event
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.sessions_bulk_revoked",
|
||||
eventData: {
|
||||
revokedCount,
|
||||
keptSession: currentSession.sessionId,
|
||||
reason: "user_request"
|
||||
},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${revokedCount} session(s) revoked successfully`,
|
||||
revokedCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error revoking other sessions:", error);
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to revoke sessions"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from "~/server/utils";
|
||||
import { setCookie, getCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import {
|
||||
linkProvider,
|
||||
findUserByProvider,
|
||||
findUserByEmail,
|
||||
updateProviderLastUsed
|
||||
} from "~/server/provider-helpers";
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
checkResponse,
|
||||
@@ -259,72 +265,96 @@ export const authRouter = createTRPCRouter({
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
console.log("[GitHub Callback] Checking if user exists...");
|
||||
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
|
||||
const params = ["github", login];
|
||||
const res = await conn.execute({ sql: query, args: params });
|
||||
|
||||
let userId: string;
|
||||
// Strategy 1: Check if this GitHub identity already linked
|
||||
let userId = await findUserByProvider("github", login);
|
||||
|
||||
if (res.rows[0]) {
|
||||
userId = (res.rows[0] as unknown as User).id;
|
||||
console.log("[GitHub Callback] Existing user found:", userId);
|
||||
let isNewUser = false;
|
||||
let isLinkedAccount = false;
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: `UPDATE User SET email = ?, email_verified = ?, image = ? WHERE id = ?`,
|
||||
args: [email, emailVerified ? 1 : 0, icon, userId]
|
||||
});
|
||||
console.log("[GitHub Callback] User data updated");
|
||||
} catch (updateError: any) {
|
||||
if (
|
||||
updateError.code === "SQLITE_CONSTRAINT" &&
|
||||
updateError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during update:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
}
|
||||
throw updateError;
|
||||
}
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[GitHub Callback] Existing GitHub provider found:",
|
||||
userId
|
||||
);
|
||||
// Update provider info
|
||||
await updateProviderLastUsed(userId, "github");
|
||||
} else {
|
||||
userId = uuidV4();
|
||||
console.log("[GitHub Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
emailVerified ? 1 : 0,
|
||||
login,
|
||||
"github",
|
||||
icon
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||
console.log("[GitHub Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during insert:",
|
||||
email
|
||||
// Strategy 2: Check if email matches existing user (account linking)
|
||||
if (email) {
|
||||
userId = await findUserByEmail(email);
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[GitHub Callback] Found existing user by email, linking GitHub account:",
|
||||
userId
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
// Link GitHub to existing account
|
||||
try {
|
||||
await linkProvider(userId, "github", {
|
||||
providerUserId: login,
|
||||
email: email,
|
||||
displayName: login,
|
||||
image: icon
|
||||
});
|
||||
isLinkedAccount = true;
|
||||
} catch (linkError: any) {
|
||||
console.error(
|
||||
"[GitHub Callback] Failed to link provider:",
|
||||
linkError.message
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: linkError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Create new user
|
||||
if (!userId) {
|
||||
userId = uuidV4();
|
||||
console.log("[GitHub Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
emailVerified ? 1 : 0,
|
||||
login,
|
||||
"github",
|
||||
icon
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "github", {
|
||||
providerUserId: login,
|
||||
email: email,
|
||||
displayName: login,
|
||||
image: icon
|
||||
});
|
||||
|
||||
isNewUser = true;
|
||||
console.log("[GitHub Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[GitHub Callback] Email conflict during insert:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account or use a different email address."
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +382,11 @@ export const authRouter = createTRPCRouter({
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.login.success",
|
||||
eventData: { method: "github", isNewUser: !res.rows[0] },
|
||||
eventData: {
|
||||
method: "github",
|
||||
isNewUser,
|
||||
isLinkedAccount
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
@@ -485,57 +519,97 @@ export const authRouter = createTRPCRouter({
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
console.log("[Google Callback] Checking if user exists...");
|
||||
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
|
||||
const params = ["google", email];
|
||||
const res = await conn.execute({ sql: query, args: params });
|
||||
|
||||
let userId: string;
|
||||
// Strategy 1: Check if this Google identity already linked
|
||||
let userId = await findUserByProvider("google", email);
|
||||
|
||||
if (res.rows[0]) {
|
||||
userId = (res.rows[0] as unknown as User).id;
|
||||
console.log("[Google Callback] Existing user found:", userId);
|
||||
let isNewUser = false;
|
||||
let isLinkedAccount = false;
|
||||
|
||||
await conn.execute({
|
||||
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
|
||||
args: [email, email_verified ? 1 : 0, name, image, userId]
|
||||
});
|
||||
console.log("[Google Callback] User data updated");
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[Google Callback] Existing Google provider found:",
|
||||
userId
|
||||
);
|
||||
// Update provider info
|
||||
await updateProviderLastUsed(userId, "google");
|
||||
} else {
|
||||
userId = uuidV4();
|
||||
console.log("[Google Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
email_verified ? 1 : 0,
|
||||
name,
|
||||
"google",
|
||||
image
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: insertQuery,
|
||||
args: insertParams
|
||||
});
|
||||
console.log("[Google Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
// Strategy 2: Check if email matches existing user (account linking)
|
||||
userId = await findUserByEmail(email);
|
||||
if (userId) {
|
||||
console.log(
|
||||
"[Google Callback] Found existing user by email, linking Google account:",
|
||||
userId
|
||||
);
|
||||
// Link Google to existing account
|
||||
try {
|
||||
await linkProvider(userId, "google", {
|
||||
providerUserId: email,
|
||||
email: email,
|
||||
displayName: name,
|
||||
image: image
|
||||
});
|
||||
isLinkedAccount = true;
|
||||
} catch (linkError: any) {
|
||||
console.error(
|
||||
"[Google Callback] Email conflict during insert:",
|
||||
email
|
||||
"[Google Callback] Failed to link provider:",
|
||||
linkError.message
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account instead."
|
||||
message: linkError.message
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
// Strategy 3: Create new user
|
||||
if (!userId) {
|
||||
userId = uuidV4();
|
||||
console.log("[Google Callback] Creating new user:", userId);
|
||||
|
||||
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const insertParams = [
|
||||
userId,
|
||||
email,
|
||||
email_verified ? 1 : 0,
|
||||
name,
|
||||
"google",
|
||||
image
|
||||
];
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: insertQuery,
|
||||
args: insertParams
|
||||
});
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "google", {
|
||||
providerUserId: email,
|
||||
email: email,
|
||||
displayName: name,
|
||||
image: image
|
||||
});
|
||||
|
||||
isNewUser = true;
|
||||
console.log("[Google Callback] New user created");
|
||||
} catch (insertError: any) {
|
||||
if (
|
||||
insertError.code === "SQLITE_CONSTRAINT" &&
|
||||
insertError.message?.includes("User.email")
|
||||
) {
|
||||
console.error(
|
||||
"[Google Callback] Email conflict during insert:",
|
||||
email
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message:
|
||||
"This email is already associated with another account. Please sign in with that account instead."
|
||||
});
|
||||
}
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,7 +637,11 @@ export const authRouter = createTRPCRouter({
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.login.success",
|
||||
eventData: { method: "google", isNewUser: !res.rows[0] },
|
||||
eventData: {
|
||||
method: "google",
|
||||
isNewUser,
|
||||
isLinkedAccount
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
@@ -989,6 +1067,36 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists (User table or UserProvider table)
|
||||
const existingUserId = await findUserByEmail(email);
|
||||
if (existingUserId) {
|
||||
// User exists - check if they have a password
|
||||
const conn = ConnectionFactory();
|
||||
const userCheck = await conn.execute({
|
||||
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
||||
args: [existingUserId]
|
||||
});
|
||||
|
||||
if (userCheck.rows.length > 0) {
|
||||
const existingUser = userCheck.rows[0] as any;
|
||||
|
||||
// If user has a password, it's a duplicate registration attempt
|
||||
if (existingUser.password_hash) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "duplicate"
|
||||
});
|
||||
}
|
||||
|
||||
// If user doesn't have a password (provider-only), redirect to login
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"An account with this email already exists. Please sign in and add a password from your account settings."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const conn = ConnectionFactory();
|
||||
const userId = uuidV4();
|
||||
@@ -999,6 +1107,12 @@ export const authRouter = createTRPCRouter({
|
||||
args: [userId, email, passwordHash, "email"]
|
||||
});
|
||||
|
||||
// Create UserProvider entry for email auth
|
||||
await linkProvider(userId, "email", {
|
||||
providerUserId: email,
|
||||
email: email
|
||||
});
|
||||
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
|
||||
@@ -4,14 +4,11 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
|
||||
import { setCookie } from "vinxi/http";
|
||||
import type { User } from "~/db/types";
|
||||
import { toUserProfile } from "~/types/user";
|
||||
import {
|
||||
updateEmailSchema,
|
||||
updateDisplayNameSchema,
|
||||
updateProfileImageSchema,
|
||||
changePasswordSchema,
|
||||
setPasswordSchema,
|
||||
deleteAccountSchema
|
||||
} from "../schemas/user";
|
||||
import { getUserProviders, unlinkProvider } from "~/server/provider-helpers";
|
||||
import { z } from "zod";
|
||||
import { getAuthSession } from "~/server/session-helpers";
|
||||
import { logAuditEvent } from "~/server/audit";
|
||||
import { getClientIP, getUserAgent } from "~/server/security";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
getProfile: publicProcedure.query(async ({ ctx }) => {
|
||||
@@ -242,6 +239,55 @@ export const userRouter = createTRPCRouter({
|
||||
args: [passwordHash, userId]
|
||||
});
|
||||
|
||||
// Send email notification about password being set
|
||||
if (user.email) {
|
||||
try {
|
||||
const { generatePasswordSetEmail } =
|
||||
await import("~/server/email-templates");
|
||||
const { formatDeviceDescription } =
|
||||
await import("~/server/device-utils");
|
||||
const { default: sendEmail } = await import("~/server/email");
|
||||
|
||||
const h3Event = ctx.event.nativeEvent
|
||||
? ctx.event.nativeEvent
|
||||
: (ctx.event as any);
|
||||
const clientIP = getClientIP(h3Event);
|
||||
const userAgent = getUserAgent(h3Event);
|
||||
|
||||
const deviceInfo = formatDeviceDescription({
|
||||
userAgent
|
||||
});
|
||||
|
||||
const providerName =
|
||||
user.provider === "google"
|
||||
? "Google"
|
||||
: user.provider === "github"
|
||||
? "GitHub"
|
||||
: "provider";
|
||||
|
||||
const htmlContent = generatePasswordSetEmail({
|
||||
providerName,
|
||||
setTime: new Date().toLocaleString(),
|
||||
deviceInfo,
|
||||
ipAddress: clientIP
|
||||
});
|
||||
|
||||
await sendEmail(
|
||||
user.email,
|
||||
"Password Added to Your Account",
|
||||
htmlContent
|
||||
);
|
||||
|
||||
console.log(`[setPassword] Confirmation email sent to ${user.email}`);
|
||||
} catch (emailError) {
|
||||
console.error(
|
||||
"[setPassword] Failed to send confirmation email:",
|
||||
emailError
|
||||
);
|
||||
// Don't fail the operation if email fails
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: "success" };
|
||||
}),
|
||||
|
||||
@@ -303,5 +349,152 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return { success: true, message: "deleted" };
|
||||
}),
|
||||
|
||||
getProviders: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
const providers = await getUserProviders(userId);
|
||||
|
||||
return providers.map((p) => ({
|
||||
id: p.id,
|
||||
provider: p.provider,
|
||||
email: p.email || undefined,
|
||||
displayName: p.display_name || undefined,
|
||||
lastUsedAt: p.last_used_at,
|
||||
createdAt: p.created_at
|
||||
}));
|
||||
}),
|
||||
|
||||
unlinkProvider: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
provider: z.enum(["email", "google", "github"])
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
await unlinkProvider(userId, input.provider);
|
||||
|
||||
return { success: true, message: "Provider unlinked" };
|
||||
}),
|
||||
|
||||
getSessions: publicProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
const res = await conn.execute({
|
||||
sql: `SELECT session_id, token_family, created_at, expires_at, last_rotated_at,
|
||||
rotation_count, client_ip, user_agent
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
ORDER BY last_rotated_at DESC`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
// Get current session to mark it
|
||||
const currentSession = await getAuthSession(ctx.event as any);
|
||||
|
||||
return res.rows.map((row: any) => ({
|
||||
sessionId: row.session_id,
|
||||
tokenFamily: row.token_family,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastRotatedAt: row.last_rotated_at,
|
||||
rotationCount: row.rotation_count,
|
||||
clientIp: row.client_ip,
|
||||
userAgent: row.user_agent,
|
||||
isCurrent: currentSession?.sessionId === row.session_id
|
||||
}));
|
||||
}),
|
||||
|
||||
revokeSession: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sessionId: z.string()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const userId = ctx.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authenticated"
|
||||
});
|
||||
}
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify session belongs to this user
|
||||
const sessionCheck = await conn.execute({
|
||||
sql: "SELECT user_id, token_family FROM Session WHERE session_id = ?",
|
||||
args: [input.sessionId]
|
||||
});
|
||||
|
||||
if (sessionCheck.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Session not found"
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionCheck.rows[0] as any;
|
||||
if (session.user_id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Cannot revoke another user's session"
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke the entire token family (all sessions on this device)
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
||||
args: [session.token_family]
|
||||
});
|
||||
|
||||
// Log audit event
|
||||
const h3Event = ctx.event.nativeEvent
|
||||
? ctx.event.nativeEvent
|
||||
: (ctx.event as any);
|
||||
const clientIP = getClientIP(h3Event);
|
||||
const userAgent = getUserAgent(h3Event);
|
||||
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.session_revoked",
|
||||
eventData: {
|
||||
sessionId: input.sessionId,
|
||||
tokenFamily: session.token_family,
|
||||
reason: "user_revoked"
|
||||
},
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
});
|
||||
|
||||
return { success: true, message: "Session revoked" };
|
||||
})
|
||||
});
|
||||
|
||||
102
src/server/device-utils.ts
Normal file
102
src/server/device-utils.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import UAParser from "ua-parser-js";
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceName?: string;
|
||||
deviceType?: "desktop" | "mobile" | "tablet";
|
||||
browser?: string;
|
||||
os?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract device information
|
||||
* @param userAgent - User agent string from request headers
|
||||
* @returns Parsed device information
|
||||
*/
|
||||
export function parseDeviceInfo(userAgent: string): DeviceInfo {
|
||||
const parser = new UAParser(userAgent);
|
||||
const result = parser.getResult();
|
||||
|
||||
// Determine device type
|
||||
let deviceType: "desktop" | "mobile" | "tablet" = "desktop";
|
||||
if (result.device.type === "mobile") {
|
||||
deviceType = "mobile";
|
||||
} else if (result.device.type === "tablet") {
|
||||
deviceType = "tablet";
|
||||
}
|
||||
|
||||
// Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro")
|
||||
let deviceName: string | undefined;
|
||||
if (result.device.vendor && result.device.model) {
|
||||
deviceName = `${result.device.vendor} ${result.device.model}`;
|
||||
} else if (result.os.name) {
|
||||
deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`;
|
||||
}
|
||||
|
||||
// Browser info (e.g., "Chrome 120")
|
||||
const browser =
|
||||
result.browser.name && result.browser.version
|
||||
? `${result.browser.name} ${result.browser.version.split(".")[0]}`
|
||||
: result.browser.name;
|
||||
|
||||
// OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17")
|
||||
const os =
|
||||
result.os.name && result.os.version
|
||||
? `${result.os.name} ${result.os.version}`
|
||||
: result.os.name;
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
deviceType,
|
||||
browser,
|
||||
os
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device information from H3Event
|
||||
* @param event - H3Event
|
||||
* @returns Device information
|
||||
*/
|
||||
export function getDeviceInfo(event: H3Event): DeviceInfo {
|
||||
const userAgent = event.node.req.headers["user-agent"] || "";
|
||||
return parseDeviceInfo(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable device description
|
||||
* @param deviceInfo - Device information
|
||||
* @returns Formatted device string (e.g., "Chrome on macOS", "iPhone")
|
||||
*/
|
||||
export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (deviceInfo.deviceName) {
|
||||
parts.push(deviceInfo.deviceName);
|
||||
}
|
||||
|
||||
if (deviceInfo.browser) {
|
||||
parts.push(deviceInfo.browser);
|
||||
}
|
||||
|
||||
if (deviceInfo.os && !deviceInfo.deviceName?.includes(deviceInfo.os)) {
|
||||
parts.push(`on ${deviceInfo.os}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short device fingerprint for comparison
|
||||
* Not cryptographic, just for grouping similar sessions
|
||||
* @param deviceInfo - Device information
|
||||
* @returns Short fingerprint string
|
||||
*/
|
||||
export function createDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
||||
const parts = [
|
||||
deviceInfo.deviceType || "unknown",
|
||||
deviceInfo.os?.split(" ")[0] || "unknown",
|
||||
deviceInfo.browser?.split(" ")[0] || "unknown"
|
||||
];
|
||||
return parts.join("-").toLowerCase();
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { AUTH_CONFIG } from "~/config";
|
||||
import loginLinkTemplate from "./login-link.html?raw";
|
||||
import passwordResetTemplate from "./password-reset.html?raw";
|
||||
import emailVerificationTemplate from "./email-verification.html?raw";
|
||||
import providerLinkedTemplate from "./provider-linked.html?raw";
|
||||
import newDeviceLoginTemplate from "./new-device-login.html?raw";
|
||||
import passwordSetTemplate from "./password-set.html?raw";
|
||||
|
||||
/**
|
||||
* Convert expiry string to human-readable format
|
||||
@@ -94,3 +97,68 @@ export function generateEmailVerificationEmail(
|
||||
EXPIRY_TIME: expiryTime
|
||||
});
|
||||
}
|
||||
|
||||
export interface ProviderLinkedEmailParams {
|
||||
providerName: string;
|
||||
providerEmail?: string;
|
||||
linkTime: string;
|
||||
deviceInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate provider linked notification email HTML
|
||||
*/
|
||||
export function generateProviderLinkedEmail(
|
||||
params: ProviderLinkedEmailParams
|
||||
): string {
|
||||
return processTemplate(providerLinkedTemplate, {
|
||||
PROVIDER_NAME: params.providerName,
|
||||
PROVIDER_EMAIL: params.providerEmail || "N/A",
|
||||
LINK_TIME: params.linkTime,
|
||||
DEVICE_INFO: params.deviceInfo
|
||||
});
|
||||
}
|
||||
|
||||
export interface NewDeviceLoginEmailParams {
|
||||
deviceInfo: string;
|
||||
loginTime: string;
|
||||
ipAddress: string;
|
||||
loginMethod: string;
|
||||
accountUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new device login notification email HTML
|
||||
*/
|
||||
export function generateNewDeviceLoginEmail(
|
||||
params: NewDeviceLoginEmailParams
|
||||
): string {
|
||||
return processTemplate(newDeviceLoginTemplate, {
|
||||
DEVICE_INFO: params.deviceInfo,
|
||||
LOGIN_TIME: params.loginTime,
|
||||
IP_ADDRESS: params.ipAddress,
|
||||
LOGIN_METHOD: params.loginMethod,
|
||||
ACCOUNT_URL: params.accountUrl
|
||||
});
|
||||
}
|
||||
|
||||
export interface PasswordSetEmailParams {
|
||||
providerName: string;
|
||||
setTime: string;
|
||||
deviceInfo: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate password set notification email HTML
|
||||
*/
|
||||
export function generatePasswordSetEmail(
|
||||
params: PasswordSetEmailParams
|
||||
): string {
|
||||
return processTemplate(passwordSetTemplate, {
|
||||
PROVIDER_NAME: params.providerName,
|
||||
SET_TIME: params.setTime,
|
||||
DEVICE_INFO: params.deviceInfo,
|
||||
IP_ADDRESS: params.ipAddress
|
||||
});
|
||||
}
|
||||
|
||||
131
src/server/email-templates/new-device-login.html
Normal file
131
src/server/email-templates/new-device-login.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>New Device Login</title>
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||
New Device Login Detected
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
"
|
||||
>
|
||||
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
We detected a new login to your account from a device we haven't seen
|
||||
before:
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 16px">
|
||||
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Time:</strong> {{LOGIN_TIME}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Location:</strong> {{IP_ADDRESS}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Method:</strong> {{LOGIN_METHOD}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
If this was you, you can safely ignore this email.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||
<strong>⚠️ Wasn't you?</strong><br />
|
||||
If you didn't log in from this device, your account may be
|
||||
compromised. Please sign in immediately and:
|
||||
</p>
|
||||
<ul
|
||||
style="
|
||||
margin: 10px 0 0 0;
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
"
|
||||
>
|
||||
<li>Revoke all active sessions</li>
|
||||
<li>Change your password</li>
|
||||
<li>Review linked authentication providers</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0">
|
||||
<a
|
||||
href="{{ACCOUNT_URL}}"
|
||||
style="
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
"
|
||||
>
|
||||
Review Account Security
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||
>
|
||||
<p style="margin: 0">
|
||||
This is an automated security notification from freno.me
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
103
src/server/email-templates/password-set.html
Normal file
103
src/server/email-templates/password-set.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Password Added to Account</title>
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||
Password Added to Your Account
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
"
|
||||
>
|
||||
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
A password has been successfully added to your account. You can now sign
|
||||
in using your email and password in addition to your existing
|
||||
authentication methods.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 16px">
|
||||
<strong>Time:</strong> {{SET_TIME}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>IP Address:</strong> {{IP_ADDRESS}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
This provides you with an additional way to access your account and
|
||||
ensures you can still sign in even if you lose access to your
|
||||
{{PROVIDER_NAME}} account.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||
<strong>⚠️ Didn't set this password?</strong><br />
|
||||
If you didn't perform this action, your account security may be at
|
||||
risk. Please sign in immediately, change your password, and review
|
||||
your account settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||
>
|
||||
<p style="margin: 0">
|
||||
This is an automated security notification from freno.me
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
102
src/server/email-templates/provider-linked.html
Normal file
102
src/server/email-templates/provider-linked.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>New Provider Linked</title>
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<h1 style="color: white; margin: 0; font-size: 24px">
|
||||
New Login Method Linked
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
"
|
||||
>
|
||||
<p style="font-size: 16px; margin-top: 0">Hello,</p>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
A new authentication provider has been linked to your account:
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 16px">
|
||||
<strong>Provider:</strong> {{PROVIDER_NAME}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Email:</strong> {{PROVIDER_EMAIL}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Time:</strong> {{LINK_TIME}}
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 14px; color: #666">
|
||||
<strong>Device:</strong> {{DEVICE_INFO}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 16px">
|
||||
You can now sign in to your account using {{PROVIDER_NAME}}.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style="
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
"
|
||||
>
|
||||
<p style="margin: 0; font-size: 14px; color: #856404">
|
||||
<strong>⚠️ Didn't link this provider?</strong><br />
|
||||
If you didn't perform this action, your account security may be at
|
||||
risk. Please sign in and remove this provider immediately, then change
|
||||
your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 14px; color: #666; margin-bottom: 0">Best regards</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="text-align: center; padding: 20px; font-size: 12px; color: #999"
|
||||
>
|
||||
<p style="margin: 0">
|
||||
This is an automated security notification from freno.me
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,76 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
import { AUTH_CONFIG } from "~/config";
|
||||
import { AUTH_CONFIG, NETWORK_CONFIG } from "~/config";
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
checkResponse,
|
||||
fetchWithRetry
|
||||
} from "~/server/fetch-utils";
|
||||
|
||||
export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
|
||||
|
||||
/**
|
||||
* Generic email sending function
|
||||
* @param to - Recipient email address
|
||||
* @param subject - Email subject
|
||||
* @param htmlContent - HTML content of the email
|
||||
* @returns Success status
|
||||
*/
|
||||
export default async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
htmlContent: string
|
||||
): Promise<{ success: boolean; messageId?: string; message?: string }> {
|
||||
const apiKey = env.SENDINBLUE_KEY;
|
||||
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||
|
||||
const emailPayload = {
|
||||
sender: {
|
||||
name: "freno.me",
|
||||
email: "no_reply@freno.me"
|
||||
},
|
||||
to: [{ email: to }],
|
||||
htmlContent,
|
||||
subject
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetchWithRetry(
|
||||
async () => {
|
||||
const res = await fetchWithTimeout(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"api-key": apiKey,
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(emailPayload),
|
||||
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
|
||||
});
|
||||
|
||||
await checkResponse(res);
|
||||
return res;
|
||||
},
|
||||
{
|
||||
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
|
||||
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
|
||||
}
|
||||
);
|
||||
|
||||
const json = (await response.json()) as { messageId?: string };
|
||||
if (json.messageId) {
|
||||
return { success: true, messageId: json.messageId };
|
||||
}
|
||||
return { success: false, message: "No messageId in response" };
|
||||
} catch (error) {
|
||||
console.error("Email sending error:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Email service error"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(userEmail: string): Promise<{
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
|
||||
244
src/server/migrate-multi-auth.ts
Normal file
244
src/server/migrate-multi-auth.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
|
||||
/**
|
||||
* Migration script to add multi-provider and enhanced session support
|
||||
* Run this script once to migrate existing database
|
||||
*/
|
||||
|
||||
export async function migrateMultiAuth() {
|
||||
const conn = ConnectionFactory();
|
||||
console.log("[Migration] Starting multi-auth migration...");
|
||||
|
||||
try {
|
||||
// Step 1: Check if UserProvider table exists
|
||||
const tableCheck = await conn.execute({
|
||||
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='UserProvider'"
|
||||
});
|
||||
|
||||
if (tableCheck.rows.length > 0) {
|
||||
console.log(
|
||||
"[Migration] UserProvider table already exists, skipping creation"
|
||||
);
|
||||
} else {
|
||||
console.log("[Migration] Creating UserProvider table...");
|
||||
await conn.execute(`
|
||||
CREATE TABLE UserProvider (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
|
||||
provider_user_id TEXT,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
image TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log("[Migration] Creating UserProvider indexes...");
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id)"
|
||||
);
|
||||
await conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email)"
|
||||
);
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id)"
|
||||
);
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider)"
|
||||
);
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email)"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Check if Session table has device columns
|
||||
const sessionColumnsCheck = await conn.execute({
|
||||
sql: "PRAGMA table_info(Session)"
|
||||
});
|
||||
const hasDeviceName = sessionColumnsCheck.rows.some(
|
||||
(row: any) => row.name === "device_name"
|
||||
);
|
||||
|
||||
if (hasDeviceName) {
|
||||
console.log(
|
||||
"[Migration] Session table already has device columns, skipping"
|
||||
);
|
||||
} else {
|
||||
console.log("[Migration] Adding device columns to Session table...");
|
||||
await conn.execute("ALTER TABLE Session ADD COLUMN device_name TEXT");
|
||||
await conn.execute("ALTER TABLE Session ADD COLUMN device_type TEXT");
|
||||
await conn.execute("ALTER TABLE Session ADD COLUMN browser TEXT");
|
||||
await conn.execute("ALTER TABLE Session ADD COLUMN os TEXT");
|
||||
|
||||
// SQLite doesn't support non-constant defaults in ALTER TABLE
|
||||
// Add column with NULL default, then update existing rows
|
||||
await conn.execute("ALTER TABLE Session ADD COLUMN last_active_at TEXT");
|
||||
|
||||
// Update existing rows to set last_active_at = last_used
|
||||
console.log(
|
||||
"[Migration] Updating existing sessions with last_active_at..."
|
||||
);
|
||||
await conn.execute(
|
||||
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
|
||||
);
|
||||
|
||||
console.log("[Migration] Creating Session indexes...");
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at)"
|
||||
);
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at)"
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Migrate existing users to UserProvider table
|
||||
console.log("[Migration] Checking for users to migrate...");
|
||||
const usersResult = await conn.execute({
|
||||
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Migration] Found ${usersResult.rows.length} users to migrate`
|
||||
);
|
||||
|
||||
let migratedCount = 0;
|
||||
for (const row of usersResult.rows) {
|
||||
const user = row as any;
|
||||
|
||||
// Skip apple provider users (they're for Life and Lineage mobile app, not website auth)
|
||||
if (user.provider === "apple") {
|
||||
console.log(
|
||||
`[Migration] Skipping user ${user.id} with apple provider (mobile app only)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already migrated
|
||||
const existingProvider = await conn.execute({
|
||||
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [user.id, user.provider || "email"]
|
||||
});
|
||||
|
||||
if (existingProvider.rows.length > 0) {
|
||||
console.log(
|
||||
`[Migration] User ${user.id} already migrated, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine provider_user_id based on provider type
|
||||
let providerUserId: string | null = null;
|
||||
if (user.provider === "github") {
|
||||
providerUserId = user.display_name;
|
||||
} else if (user.provider === "google") {
|
||||
providerUserId = user.email;
|
||||
} else {
|
||||
providerUserId = user.email;
|
||||
}
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
uuidV4(),
|
||||
user.id,
|
||||
user.provider || "email",
|
||||
providerUserId,
|
||||
user.email,
|
||||
user.display_name,
|
||||
user.image
|
||||
]
|
||||
});
|
||||
migratedCount++;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Migration] Failed to migrate user ${user.id}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider_user_id based on provider type
|
||||
let providerUserId: string | null = null;
|
||||
if (user.provider === "github") {
|
||||
providerUserId = user.display_name;
|
||||
} else if (user.provider === "google") {
|
||||
providerUserId = user.email;
|
||||
} else if (user.provider === "apple") {
|
||||
providerUserId = user.apple_user_string;
|
||||
} else {
|
||||
providerUserId = user.email;
|
||||
}
|
||||
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
uuidV4(),
|
||||
user.id,
|
||||
user.provider || "email",
|
||||
providerUserId,
|
||||
user.email,
|
||||
user.display_name,
|
||||
user.image
|
||||
]
|
||||
});
|
||||
migratedCount++;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Migration] Failed to migrate user ${user.id}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration] Migrated ${migratedCount} users successfully`);
|
||||
|
||||
// Step 4: Verification
|
||||
console.log("[Migration] Running verification queries...");
|
||||
const providerCount = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM UserProvider"
|
||||
});
|
||||
console.log(
|
||||
`[Migration] Total providers in UserProvider table: ${(providerCount.rows[0] as any).count}`
|
||||
);
|
||||
|
||||
const multiProviderUsers = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM (
|
||||
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
|
||||
)`
|
||||
});
|
||||
console.log(
|
||||
`[Migration] Users with multiple providers: ${(multiProviderUsers.rows[0] as any).count}`
|
||||
);
|
||||
|
||||
console.log("[Migration] Multi-auth migration completed successfully!");
|
||||
return {
|
||||
success: true,
|
||||
migratedUsers: migratedCount,
|
||||
totalProviders: (providerCount.rows[0] as any).count
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Migration] Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migrateMultiAuth()
|
||||
.then((result) => {
|
||||
console.log("[Migration] Result:", result);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Migration] Error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
350
src/server/provider-helpers.ts
Normal file
350
src/server/provider-helpers.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import type { UserProvider } from "~/db/types";
|
||||
import { logAuditEvent } from "./audit";
|
||||
import { generateProviderLinkedEmail } from "./email-templates";
|
||||
import { formatDeviceDescription } from "./device-utils";
|
||||
|
||||
/**
|
||||
* Link a new authentication provider to an existing user account
|
||||
* @param userId - User ID to link provider to
|
||||
* @param provider - Provider type
|
||||
* @param providerData - Provider-specific data
|
||||
* @param options - Optional parameters (deviceInfo, sendEmail)
|
||||
* @returns Created UserProvider record
|
||||
*/
|
||||
export async function linkProvider(
|
||||
userId: string,
|
||||
provider: "email" | "google" | "github",
|
||||
providerData: {
|
||||
providerUserId?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
image?: string;
|
||||
},
|
||||
options?: {
|
||||
deviceInfo?: {
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
};
|
||||
sendEmail?: boolean;
|
||||
}
|
||||
): Promise<UserProvider> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Check if provider already linked to this user
|
||||
const existing = await conn.execute({
|
||||
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
});
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
throw new Error(`Provider ${provider} already linked to this account`);
|
||||
}
|
||||
|
||||
// Check if provider identity is already used by another user
|
||||
if (providerData.providerUserId) {
|
||||
const conflictCheck = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||
args: [provider, providerData.providerUserId]
|
||||
});
|
||||
|
||||
if (conflictCheck.rows.length > 0) {
|
||||
const conflictUserId = (conflictCheck.rows[0] as any).user_id;
|
||||
if (conflictUserId !== userId) {
|
||||
throw new Error(
|
||||
`This ${provider} account is already linked to a different user`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new provider link
|
||||
const id = uuidV4();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
id,
|
||||
userId,
|
||||
provider,
|
||||
providerData.providerUserId || null,
|
||||
providerData.email || null,
|
||||
providerData.displayName || null,
|
||||
providerData.image || null
|
||||
]
|
||||
});
|
||||
|
||||
// Fetch created record
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT * FROM UserProvider WHERE id = ?",
|
||||
args: [id]
|
||||
});
|
||||
|
||||
const userProvider = result.rows[0] as unknown as UserProvider;
|
||||
|
||||
// Log audit event
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.linked",
|
||||
eventData: {
|
||||
provider,
|
||||
providerEmail: providerData.email
|
||||
},
|
||||
success: true
|
||||
});
|
||||
|
||||
// Send notification email if requested and user has email
|
||||
if (options?.sendEmail !== false) {
|
||||
try {
|
||||
// Get user email
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT email FROM User WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
const userEmail = userResult.rows[0]
|
||||
? ((userResult.rows[0] as any).email as string)
|
||||
: null;
|
||||
|
||||
if (userEmail) {
|
||||
const deviceDescription = options?.deviceInfo
|
||||
? formatDeviceDescription(options.deviceInfo)
|
||||
: "Unknown Device";
|
||||
|
||||
const htmlContent = generateProviderLinkedEmail({
|
||||
providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
providerEmail: providerData.email,
|
||||
linkTime: new Date().toLocaleString(),
|
||||
deviceInfo: deviceDescription
|
||||
});
|
||||
|
||||
// Import sendEmail dynamically to avoid circular dependency
|
||||
const { default: sendEmail } = await import("./email");
|
||||
await sendEmail(
|
||||
userEmail,
|
||||
"New Authentication Provider Linked",
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
// Don't fail the operation if email fails
|
||||
console.error("Failed to send provider linked email:", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return userProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an authentication provider from a user account
|
||||
* @param userId - User ID
|
||||
* @param provider - Provider to unlink
|
||||
* @throws Error if trying to remove last provider
|
||||
*/
|
||||
export async function unlinkProvider(
|
||||
userId: string,
|
||||
provider: "email" | "google" | "github"
|
||||
): Promise<void> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Check how many providers this user has
|
||||
const providersResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
const providerCount = (providersResult.rows[0] as any).count;
|
||||
|
||||
if (providerCount <= 1) {
|
||||
throw new Error(
|
||||
"Cannot remove last authentication method. Add another provider first."
|
||||
);
|
||||
}
|
||||
|
||||
// Delete provider
|
||||
const result = await conn.execute({
|
||||
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
});
|
||||
|
||||
if ((result as any).rowsAffected === 0) {
|
||||
throw new Error(`Provider ${provider} not found for this user`);
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.unlinked",
|
||||
eventData: {
|
||||
provider
|
||||
},
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all authentication providers for a user
|
||||
* @param userId - User ID
|
||||
* @returns Array of UserProvider records
|
||||
*/
|
||||
export async function getUserProviders(
|
||||
userId: string
|
||||
): Promise<UserProvider[]> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT * FROM UserProvider WHERE user_id = ? ORDER BY created_at ASC",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
return result.rows as unknown as UserProvider[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by provider and provider-specific identifier
|
||||
* @param provider - Provider type
|
||||
* @param providerUserId - Provider-specific user ID
|
||||
* @returns User ID if found, null otherwise
|
||||
*/
|
||||
export async function findUserByProvider(
|
||||
provider: "email" | "google" | "github",
|
||||
providerUserId: string
|
||||
): Promise<string | null> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||
args: [provider, providerUserId]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (result.rows[0] as any).user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by provider and email
|
||||
* Used for account linking when email matches
|
||||
* @param provider - Provider type
|
||||
* @param email - Email address
|
||||
* @returns User ID if found, null otherwise
|
||||
*/
|
||||
export async function findUserByProviderEmail(
|
||||
provider: "email" | "google" | "github",
|
||||
email: string
|
||||
): Promise<string | null> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND email = ?",
|
||||
args: [provider, email]
|
||||
});
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (result.rows[0] as any).user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any user by email across all providers
|
||||
* Used for cross-provider account linking
|
||||
* @param email - Email address
|
||||
* @returns User ID if found, null otherwise
|
||||
*/
|
||||
export async function findUserByEmail(email: string): Promise<string | null> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// First check User table
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT id FROM User WHERE email = ?",
|
||||
args: [email]
|
||||
});
|
||||
|
||||
if (userResult.rows.length > 0) {
|
||||
return (userResult.rows[0] as any).id;
|
||||
}
|
||||
|
||||
// Then check UserProvider table
|
||||
const providerResult = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
|
||||
args: [email]
|
||||
});
|
||||
|
||||
if (providerResult.rows.length > 0) {
|
||||
return (providerResult.rows[0] as any).user_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last_used_at timestamp for a provider
|
||||
* Call this on successful login with that provider
|
||||
* @param userId - User ID
|
||||
* @param provider - Provider that was used
|
||||
*/
|
||||
export async function updateProviderLastUsed(
|
||||
userId: string,
|
||||
provider: "email" | "google" | "github"
|
||||
): Promise<void> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
await conn.execute({
|
||||
sql: "UPDATE UserProvider SET last_used_at = datetime('now') WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific provider linked
|
||||
* @param userId - User ID
|
||||
* @param provider - Provider to check
|
||||
* @returns true if linked, false otherwise
|
||||
*/
|
||||
export async function hasProvider(
|
||||
userId: string,
|
||||
provider: "email" | "google" | "github"
|
||||
): Promise<boolean> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT id FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
});
|
||||
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider summary for a user (for display purposes)
|
||||
* @param userId - User ID
|
||||
* @returns Summary of linked providers
|
||||
*/
|
||||
export async function getProviderSummary(userId: string): Promise<{
|
||||
providers: Array<{
|
||||
provider: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
lastUsed: string;
|
||||
}>;
|
||||
count: number;
|
||||
}> {
|
||||
const providers = await getUserProviders(userId);
|
||||
|
||||
return {
|
||||
providers: providers.map((p) => ({
|
||||
provider: p.provider,
|
||||
email: p.email || undefined,
|
||||
displayName: p.display_name || undefined,
|
||||
lastUsed: p.last_used_at
|
||||
})),
|
||||
count: providers.length
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { AUTH_CONFIG, expiryToSeconds } from "~/config";
|
||||
import { logAuditEvent } from "./audit";
|
||||
import type { SessionData } from "./session-config";
|
||||
import { sessionConfig } from "./session-config";
|
||||
import { getDeviceInfo } from "./device-utils";
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure refresh token
|
||||
@@ -61,6 +62,9 @@ export async function createAuthSession(
|
||||
const refreshToken = generateRefreshToken();
|
||||
const tokenHash = hashRefreshToken(refreshToken);
|
||||
|
||||
// Parse device information
|
||||
const deviceInfo = getDeviceInfo(event);
|
||||
|
||||
// Calculate refresh token expiration
|
||||
const refreshExpiry = rememberMe
|
||||
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
||||
@@ -102,12 +106,13 @@ export async function createAuthSession(
|
||||
}
|
||||
}
|
||||
|
||||
// Insert session into database
|
||||
// Insert session into database with device metadata
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO Session
|
||||
(id, user_id, token_family, refresh_token_hash, parent_session_id,
|
||||
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent,
|
||||
device_name, device_type, browser, os, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||
args: [
|
||||
sessionId,
|
||||
userId,
|
||||
@@ -118,7 +123,11 @@ export async function createAuthSession(
|
||||
expiresAt.toISOString(),
|
||||
accessExpiresAt.toISOString(),
|
||||
ipAddress,
|
||||
userAgent
|
||||
userAgent,
|
||||
deviceInfo.deviceName || null,
|
||||
deviceInfo.deviceType || null,
|
||||
deviceInfo.browser || null,
|
||||
deviceInfo.os || null
|
||||
]
|
||||
});
|
||||
|
||||
@@ -152,7 +161,9 @@ export async function createAuthSession(
|
||||
sessionId,
|
||||
tokenFamily: family,
|
||||
rememberMe,
|
||||
parentSessionId
|
||||
parentSessionId,
|
||||
deviceName: deviceInfo.deviceName,
|
||||
deviceType: deviceInfo.deviceType
|
||||
},
|
||||
success: true
|
||||
});
|
||||
@@ -299,14 +310,14 @@ async function validateSessionInDB(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update last_used timestamp (fire and forget)
|
||||
// Update last_used and last_active_at timestamps (fire and forget)
|
||||
conn
|
||||
.execute({
|
||||
sql: "UPDATE Session SET last_used = datetime('now') WHERE id = ?",
|
||||
sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
|
||||
args: [sessionId]
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error("Failed to update session last_used:", err)
|
||||
console.error("Failed to update session timestamps:", err)
|
||||
);
|
||||
|
||||
return true;
|
||||
|
||||
195
src/server/session-management.ts
Normal file
195
src/server/session-management.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { ConnectionFactory } from "./database";
|
||||
import type { Session } from "~/db/types";
|
||||
import { formatDeviceDescription } from "./device-utils";
|
||||
|
||||
/**
|
||||
* Get all active sessions for a user
|
||||
* @param userId - User ID
|
||||
* @returns Array of active sessions with formatted device info
|
||||
*/
|
||||
export async function getUserActiveSessions(userId: string): Promise<
|
||||
Array<{
|
||||
sessionId: string;
|
||||
deviceDescription: string;
|
||||
deviceType?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
ipAddress?: string;
|
||||
lastActive: string;
|
||||
createdAt: string;
|
||||
current: boolean;
|
||||
}>
|
||||
> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT
|
||||
id, device_name, device_type, browser, os,
|
||||
ip_address, last_active_at, created_at, token_family
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
ORDER BY last_active_at DESC`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
return result.rows.map((row: any) => {
|
||||
const deviceInfo = {
|
||||
deviceName: row.device_name,
|
||||
deviceType: row.device_type,
|
||||
browser: row.browser,
|
||||
os: row.os
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: row.id,
|
||||
deviceDescription: formatDeviceDescription(deviceInfo),
|
||||
deviceType: row.device_type,
|
||||
browser: row.browser,
|
||||
os: row.os,
|
||||
ipAddress: row.ip_address,
|
||||
lastActive: row.last_active_at,
|
||||
createdAt: row.created_at,
|
||||
current: false // Will be set by caller if needed
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session (not entire token family)
|
||||
* Useful for "logout from this device" functionality
|
||||
* @param userId - User ID (for verification)
|
||||
* @param sessionId - Session ID to revoke
|
||||
* @throws Error if session not found or doesn't belong to user
|
||||
*/
|
||||
export async function revokeUserSession(
|
||||
userId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify session belongs to user
|
||||
const verifyResult = await conn.execute({
|
||||
sql: "SELECT user_id FROM Session WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
|
||||
if (verifyResult.rows.length === 0) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const sessionUserId = (verifyResult.rows[0] as any).user_id;
|
||||
if (sessionUserId !== userId) {
|
||||
throw new Error("Session does not belong to this user");
|
||||
}
|
||||
|
||||
// Revoke the session
|
||||
await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
||||
args: [sessionId]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for a user EXCEPT the current one
|
||||
* Useful for "logout from all other devices"
|
||||
* @param userId - User ID
|
||||
* @param currentSessionId - Current session ID to keep active
|
||||
* @returns Number of sessions revoked
|
||||
*/
|
||||
export async function revokeOtherUserSessions(
|
||||
userId: string,
|
||||
currentSessionId: string
|
||||
): Promise<number> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: "UPDATE Session SET revoked = 1 WHERE user_id = ? AND id != ? AND revoked = 0",
|
||||
args: [userId, currentSessionId]
|
||||
});
|
||||
|
||||
return (result as any).rowsAffected || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session count by device type for a user
|
||||
* @param userId - User ID
|
||||
* @returns Object with counts by device type
|
||||
*/
|
||||
export async function getSessionCountByDevice(userId: string): Promise<{
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
tablet: number;
|
||||
unknown: number;
|
||||
total: number;
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT
|
||||
device_type,
|
||||
COUNT(*) as count
|
||||
FROM Session
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > datetime('now')
|
||||
GROUP BY device_type`,
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
const counts = {
|
||||
desktop: 0,
|
||||
mobile: 0,
|
||||
tablet: 0,
|
||||
unknown: 0,
|
||||
total: 0
|
||||
};
|
||||
|
||||
for (const row of result.rows) {
|
||||
const deviceType = (row as any).device_type;
|
||||
const count = (row as any).count;
|
||||
|
||||
if (deviceType === "desktop") {
|
||||
counts.desktop = count;
|
||||
} else if (deviceType === "mobile") {
|
||||
counts.mobile = count;
|
||||
} else if (deviceType === "tablet") {
|
||||
counts.tablet = count;
|
||||
} else {
|
||||
counts.unknown = count;
|
||||
}
|
||||
|
||||
counts.total += count;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific device fingerprint already has an active session
|
||||
* Can be used to show "You're already logged in on this device" messages
|
||||
* @param userId - User ID
|
||||
* @param deviceType - Device type
|
||||
* @param browser - Browser name
|
||||
* @param os - OS name
|
||||
* @returns true if device has active session
|
||||
*/
|
||||
export async function hasActiveSessionOnDevice(
|
||||
userId: string,
|
||||
deviceType?: string,
|
||||
browser?: string,
|
||||
os?: string
|
||||
): Promise<boolean> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT id FROM Session
|
||||
WHERE user_id = ?
|
||||
AND device_type = ?
|
||||
AND browser = ?
|
||||
AND os = ?
|
||||
AND revoked = 0
|
||||
AND expires_at > datetime('now')
|
||||
LIMIT 1`,
|
||||
args: [userId, deviceType || null, browser || null, os || null]
|
||||
});
|
||||
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user