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 caa13fd..5ab1b37 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 +

+
+ +
+

Hello,

+ +

+ We detected a new login to your account from a device we haven't seen + before: +

+ +
+

+ Device: {{DEVICE_INFO}} +

+

+ Time: {{LOGIN_TIME}} +

+

+ Location: {{IP_ADDRESS}} +

+

+ Method: {{LOGIN_METHOD}} +

+
+ +

+ If this was you, you can safely ignore this email. +

+ +
+

+ ⚠️ Wasn't you?
+ If you didn't log in from this device, your account may be + compromised. Please sign in immediately and: +

+
    +
  • Revoke all active sessions
  • +
  • Change your password
  • +
  • Review linked authentication providers
  • +
+
+ +
+ + Review Account Security + +
+ +

Best regards

+
+ +
+

+ 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; +}