From f056cf05d31e374c151b57792a9f76d401811240 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 7 Jan 2026 20:19:26 -0500 Subject: [PATCH] hopeful --- app.config.ts | 46 +-- bun.lockb | Bin 473716 -> 475212 bytes package.json | 1 + src/db/create.ts | 27 ++ src/db/types.ts | 17 + src/routes/account.tsx | 288 +++++++++++++- src/routes/login/index.tsx | 17 +- src/server/api/root.ts | 4 +- src/server/api/routers/account.ts | 255 +++++++++++++ src/server/api/routers/auth.ts | 320 ++++++++++------ src/server/api/routers/user.ts | 209 ++++++++++- src/server/device-utils.ts | 102 +++++ src/server/email-templates/index.ts | 68 ++++ .../email-templates/new-device-login.html | 131 +++++++ src/server/email-templates/password-set.html | 103 ++++++ .../email-templates/provider-linked.html | 102 +++++ src/server/email.ts | 69 +++- src/server/migrate-multi-auth.ts | 244 ++++++++++++ src/server/provider-helpers.ts | 350 ++++++++++++++++++ src/server/session-helpers.ts | 27 +- src/server/session-management.ts | 195 ++++++++++ 21 files changed, 2407 insertions(+), 168 deletions(-) create mode 100644 src/server/api/routers/account.ts create mode 100644 src/server/device-utils.ts create mode 100644 src/server/email-templates/new-device-login.html create mode 100644 src/server/email-templates/password-set.html create mode 100644 src/server/email-templates/provider-linked.html create mode 100644 src/server/migrate-multi-auth.ts create mode 100644 src/server/provider-helpers.ts create mode 100644 src/server/session-management.ts diff --git a/app.config.ts b/app.config.ts index bb795fb..44f2736 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,51 +3,7 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ vite: { - plugins: [tailwindcss()], - build: { - rollupOptions: { - output: { - manualChunks: (id) => { - // Bundle highlight.js and lowlight together - if (id.includes("highlight.js") || id.includes("lowlight")) { - return "highlight"; - } - - // Bundle Mermaid separately (large library, only used on some posts) - if (id.includes("mermaid")) { - return "mermaid"; - } - - // Bundle all Tiptap extensions together (only used in editor) - if (id.includes("@tiptap") || id.includes("solid-tiptap")) { - return "tiptap"; - } - - // Bundle motion libraries - if (id.includes("motion") || id.includes("@motionone")) { - return "motion"; - } - - // Split other large vendor libraries - if (id.includes("node_modules")) { - // Keep all solid-related packages together to avoid circular deps - if ( - id.includes("@solidjs") || - id.includes("solid-js") || - id.includes("seroval") - ) { - return "solid"; - } - if (id.includes("@trpc")) { - return "trpc"; - } - // Don't create a generic vendor chunk - let Vite handle it - // to avoid circular dependencies with solid - } - } - } - } - } + plugins: [tailwindcss()] }, server: { preset: "vercel" diff --git a/bun.lockb b/bun.lockb index caa13fd71f4126a6359c26db01430d0980c93216..5ab1b376472c04c26d5dafaf4e31142758164efb 100755 GIT binary patch delta 82815 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

^4f)UhhM9?L$~ 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

F{vyT_OAC?d%s=rw66HOGs;uFKu3qdB?Q3y$n)$ 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 diff --git a/package.json b/package.json index ed14721..27ad92d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "redis": "^5.10.0", "solid-js": "^1.9.5", "solid-tiptap": "^0.8.0", + "ua-parser-js": "^2.0.7", "uuid": "^13.0.0", "vinxi": "^0.5.7", "zod": "^4.2.1" diff --git a/src/db/create.ts b/src/db/create.ts index 4a9635a..dccd7b6 100644 --- a/src/db/create.ts +++ b/src/db/create.ts @@ -30,6 +30,11 @@ export const model: { [key: string]: string } = { ip_address TEXT, user_agent TEXT, revoked INTEGER DEFAULT 0, + device_name TEXT, + device_type TEXT, + browser TEXT, + os TEXT, + last_active_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE, FOREIGN KEY (parent_session_id) REFERENCES Session(id) ON DELETE SET NULL ); @@ -38,6 +43,28 @@ export const model: { [key: string]: string } = { CREATE INDEX IF NOT EXISTS idx_session_token_family ON Session (token_family); CREATE INDEX IF NOT EXISTS idx_session_refresh_token_hash ON Session (refresh_token_hash); CREATE INDEX IF NOT EXISTS idx_session_revoked ON Session (revoked); + CREATE INDEX IF NOT EXISTS idx_session_last_active ON Session (last_active_at); + CREATE INDEX IF NOT EXISTS idx_session_user_active ON Session (user_id, revoked, last_active_at); + `, + UserProvider: ` + 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 + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email); + CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id); + CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider); + CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email); `, PasswordResetToken: ` CREATE TABLE PasswordResetToken diff --git a/src/db/types.ts b/src/db/types.ts index f6cff5a..e2716a8 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -27,6 +27,23 @@ export interface Session { ip_address?: string | null; user_agent?: string | null; revoked: number; + device_name?: string | null; + device_type?: string | null; + browser?: string | null; + os?: string | null; + last_active_at?: string | null; +} + +export interface UserProvider { + id: string; + user_id: string; + provider: "email" | "google" | "github" | "apple"; // apple is for Life and Lineage mobile app only + provider_user_id?: string | null; + email?: string | null; + display_name?: string | null; + image?: string | null; + created_at: string; + last_used_at: string; } export interface PasswordResetToken { diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 2dff9fe..180b9af 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, createEffect } from "solid-js"; +import { createSignal, Show, createEffect, For } from "solid-js"; import { PageHead } from "~/components/PageHead"; import { useNavigate, redirect, query, createAsync } from "@solidjs/router"; import XCircle from "~/components/icons/XCircle"; @@ -858,6 +858,30 @@ export default function AccountPage() {


+ {/* Linked Providers Section */} +
+
+ Linked Authentication Methods +
+
+ +
+
+ +
+ + {/* Active Sessions Section */} +
+
+ Active Sessions +
+
+ +
+
+ +
+ {/* Sign Out Section */}
+ + +
Primary method
+
+
+ )} + + + ); +} + +function ActiveSessions(props: { userId: string }) { + const [sessions, setSessions] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [revokeLoading, setRevokeLoading] = createSignal(null); + + const loadSessions = async () => { + try { + const response = await fetch("/api/trpc/user.getSessions"); + const result = await response.json(); + if (response.ok && result.result?.data) { + setSessions(result.result.data); + } + } catch (err) { + console.error("Failed to load sessions:", err); + } finally { + setLoading(false); + } + }; + + createEffect(() => { + loadSessions(); + }); + + const handleRevoke = async (sessionId: string, isCurrent: boolean) => { + if (isCurrent) { + if ( + !confirm( + "This will sign you out of this device. Are you sure you want to continue?" + ) + ) { + return; + } + } else { + if (!confirm("Are you sure you want to revoke this session?")) { + return; + } + } + + setRevokeLoading(sessionId); + try { + const response = await fetch("/api/trpc/user.revokeSession", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }) + }); + + const result = await response.json(); + if (response.ok && result.result?.data?.success) { + if (isCurrent) { + window.location.href = "/login"; + } else { + await loadSessions(); + alert("Session revoked successfully"); + } + } else { + alert(result.error?.message || "Failed to revoke session"); + } + } catch (err) { + console.error("Failed to revoke session:", err); + alert("Failed to revoke session"); + } finally { + setRevokeLoading(null); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + }; + + const parseUserAgent = (ua: string) => { + const browser = + ua.match(/(Chrome|Firefox|Safari|Edge)\/[\d.]+/)?.[0] || + "Unknown browser"; + const os = ua.match(/(Windows|Mac|Linux|Android|iOS)/)?.[0] || "Unknown OS"; + return { browser, os }; + }; + + return ( +
+ +
Loading sessions...
+
+ +
No active sessions found
+
+ + {(session) => { + const { browser, os } = parseUserAgent(session.userAgent || ""); + return ( +
+
+
+
+
{browser}
+ + + Current + + +
+
+
{os}
+ +
IP: {session.clientIp}
+
+
+ Last active:{" "} + {formatDate(session.lastRotatedAt || session.createdAt)} +
+ +
+ Expires: {formatDate(session.expiresAt)} +
+
+
+
+ +
+
+ ); + }} +
+
+ ); +} diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index bbae566..ebc9ea4 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -217,7 +217,11 @@ export default function LoginPage() { errorMsg.includes("duplicate") || errorMsg.includes("already exists") ) { - setError("duplicate"); + if (errorMsg.includes("sign in and add a password")) { + setError("provider_exists"); + } else { + setError("duplicate"); + } } else { setError(errorMsg); } @@ -423,6 +427,16 @@ export default function LoginPage() { Email Already Exists! + +
+ Account Already Exists +
+
+ An account with this email already exists. Please sign in + using your provider (Google/GitHub) and add a password from + your account settings. +
+
{ + 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" + }); + } + }) +}); diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index dd5a54f..ad0a771 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -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)); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 69f7a8f..3c839c4 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -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" }; }) }); diff --git a/src/server/device-utils.ts b/src/server/device-utils.ts new file mode 100644 index 0000000..81caf19 --- /dev/null +++ b/src/server/device-utils.ts @@ -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(); +} diff --git a/src/server/email-templates/index.ts b/src/server/email-templates/index.ts index 646615a..63879fb 100644 --- a/src/server/email-templates/index.ts +++ b/src/server/email-templates/index.ts @@ -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 + }); +} diff --git a/src/server/email-templates/new-device-login.html b/src/server/email-templates/new-device-login.html new file mode 100644 index 0000000..8e737b7 --- /dev/null +++ b/src/server/email-templates/new-device-login.html @@ -0,0 +1,131 @@ + + + + + + New Device Login + + +
+

+ New Device Login Detected +

+
+ +
+ +
+

+ This is an automated security notification from freno.me +

+
+ + diff --git a/src/server/email-templates/password-set.html b/src/server/email-templates/password-set.html new file mode 100644 index 0000000..ac626ea --- /dev/null +++ b/src/server/email-templates/password-set.html @@ -0,0 +1,103 @@ + + + + + + Password Added to Account + + +
+

+ Password Added to Your Account +

+
+ +
+

Hello,

+ +

+ 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. +

+ +
+

+ Time: {{SET_TIME}} +

+

+ Device: {{DEVICE_INFO}} +

+

+ IP Address: {{IP_ADDRESS}} +

+
+ +

+ 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. +

+ +
+

+ ⚠️ Didn't set this password?
+ 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. +

+
+ +

Best regards

+
+ +
+

+ This is an automated security notification from freno.me +

+
+ + diff --git a/src/server/email-templates/provider-linked.html b/src/server/email-templates/provider-linked.html new file mode 100644 index 0000000..215991f --- /dev/null +++ b/src/server/email-templates/provider-linked.html @@ -0,0 +1,102 @@ + + + + + + New Provider Linked + + +
+

+ New Login Method Linked +

+
+ +
+

Hello,

+ +

+ A new authentication provider has been linked to your account: +

+ +
+

+ Provider: {{PROVIDER_NAME}} +

+

+ Email: {{PROVIDER_EMAIL}} +

+

+ Time: {{LINK_TIME}} +

+

+ Device: {{DEVICE_INFO}} +

+
+ +

+ You can now sign in to your account using {{PROVIDER_NAME}}. +

+ +
+

+ ⚠️ Didn't link this provider?
+ 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. +

+
+ +

Best regards

+
+ +
+

+ This is an automated security notification from freno.me +

+
+ + diff --git a/src/server/email.ts b/src/server/email.ts index 7f9bd42..02fbd17 100644 --- a/src/server/email.ts +++ b/src/server/email.ts @@ -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; diff --git a/src/server/migrate-multi-auth.ts b/src/server/migrate-multi-auth.ts new file mode 100644 index 0000000..6505289 --- /dev/null +++ b/src/server/migrate-multi-auth.ts @@ -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); + }); +} diff --git a/src/server/provider-helpers.ts b/src/server/provider-helpers.ts new file mode 100644 index 0000000..d669f77 --- /dev/null +++ b/src/server/provider-helpers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; +} diff --git a/src/server/session-helpers.ts b/src/server/session-helpers.ts index 4d12a86..5069f08 100644 --- a/src/server/session-helpers.ts +++ b/src/server/session-helpers.ts @@ -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; diff --git a/src/server/session-management.ts b/src/server/session-management.ts new file mode 100644 index 0000000..96b4907 --- /dev/null +++ b/src/server/session-management.ts @@ -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 { + 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 { + 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 { + 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; +}