session state simplification
This commit is contained in:
@@ -306,14 +306,11 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userId === env.ADMIN_ID;
|
||||
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
userId,
|
||||
isAdmin,
|
||||
true, // OAuth defaults to remember
|
||||
clientIP,
|
||||
userAgent
|
||||
@@ -521,15 +518,12 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = userId === env.ADMIN_ID;
|
||||
|
||||
// Create session with Vinxi (OAuth defaults to remember me)
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
userId,
|
||||
isAdmin,
|
||||
true, // OAuth defaults to remember
|
||||
clientIP,
|
||||
userAgent
|
||||
@@ -647,7 +641,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const userId = (res.rows[0] as unknown as User).id;
|
||||
const isAdmin = userId === env.ADMIN_ID;
|
||||
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
@@ -655,7 +648,6 @@ export const authRouter = createTRPCRouter({
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
userId,
|
||||
isAdmin,
|
||||
rememberMe,
|
||||
clientIP,
|
||||
userAgent
|
||||
@@ -780,7 +772,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const userId = (res.rows[0] as unknown as User).id;
|
||||
const isAdmin = userId === env.ADMIN_ID;
|
||||
|
||||
// Use rememberMe from JWT if not provided in input, default to false
|
||||
const shouldRemember =
|
||||
@@ -791,7 +782,6 @@ export const authRouter = createTRPCRouter({
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
userId,
|
||||
isAdmin,
|
||||
shouldRemember,
|
||||
clientIP,
|
||||
userAgent
|
||||
@@ -983,12 +973,10 @@ export const authRouter = createTRPCRouter({
|
||||
// Create session with client info
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
const isAdmin = userId === env.ADMIN_ID;
|
||||
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
userId,
|
||||
isAdmin,
|
||||
true, // Always use persistent sessions
|
||||
clientIP,
|
||||
userAgent
|
||||
@@ -1150,14 +1138,11 @@ export const authRouter = createTRPCRouter({
|
||||
// Reset rate limits on successful login
|
||||
await resetLoginRateLimits(email, clientIP);
|
||||
|
||||
const isAdmin = user.id === env.ADMIN_ID;
|
||||
|
||||
// Create session with Vinxi
|
||||
const userAgent = getUserAgent(getH3Event(ctx));
|
||||
await createAuthSession(
|
||||
getH3Event(ctx),
|
||||
user.id,
|
||||
isAdmin,
|
||||
rememberMe ?? false, // Default to session cookie (expires on browser close)
|
||||
clientIP,
|
||||
userAgent
|
||||
|
||||
@@ -7,9 +7,9 @@ import { CACHE_CONFIG } from "~/config";
|
||||
|
||||
const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
|
||||
|
||||
const getAllPostsData = async (privilegeLevel: string) => {
|
||||
const getAllPostsData = async (isAdmin: boolean) => {
|
||||
return withCacheAndStale(
|
||||
`blog-posts-${privilegeLevel}`,
|
||||
`blog-posts-${isAdmin ? "admin" : "public"}`,
|
||||
BLOG_CACHE_TTL,
|
||||
async () => {
|
||||
const conn = ConnectionFactory();
|
||||
@@ -34,7 +34,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
|
||||
LEFT JOIN Comment c ON p.id = c.post_id
|
||||
`;
|
||||
|
||||
if (privilegeLevel !== "admin") {
|
||||
if (!isAdmin) {
|
||||
postsQuery += ` WHERE p.published = TRUE`;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const getAllPostsData = async (privilegeLevel: string) => {
|
||||
SELECT t.value, t.post_id
|
||||
FROM Tag t
|
||||
JOIN Post p ON t.post_id = p.id
|
||||
${privilegeLevel !== "admin" ? "WHERE p.published = TRUE" : ""}
|
||||
${!isAdmin ? "WHERE p.published = TRUE" : ""}
|
||||
ORDER BY t.value ASC
|
||||
`;
|
||||
|
||||
@@ -64,21 +64,21 @@ const getAllPostsData = async (privilegeLevel: string) => {
|
||||
tagMap[key] = (tagMap[key] || 0) + 1;
|
||||
});
|
||||
|
||||
return { posts, tags, tagMap, privilegeLevel };
|
||||
return { posts, tags, tagMap, isAdmin };
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const blogRouter = createTRPCRouter({
|
||||
getRecentPosts: publicProcedure.query(async ({ ctx }) => {
|
||||
const allPostsData = await getAllPostsData("public");
|
||||
const allPostsData = await getAllPostsData(false);
|
||||
|
||||
return allPostsData.posts.slice(0, 3);
|
||||
}),
|
||||
|
||||
getPosts: publicProcedure.query(async ({ ctx }) => {
|
||||
const privilegeLevel = ctx.privilegeLevel;
|
||||
return getAllPostsData(privilegeLevel);
|
||||
const isAdmin = ctx.isAdmin;
|
||||
return getAllPostsData(isAdmin);
|
||||
}),
|
||||
|
||||
incrementPostRead: publicProcedure
|
||||
|
||||
@@ -144,7 +144,7 @@ export const databaseRouter = createTRPCRouter({
|
||||
commentID: input.commentID,
|
||||
deletionType: input.deletionType,
|
||||
userId: ctx.userId,
|
||||
privilegeLevel: ctx.privilegeLevel
|
||||
isAdmin: ctx.isAdmin
|
||||
});
|
||||
|
||||
const commentQuery = await conn.execute({
|
||||
@@ -161,7 +161,7 @@ export const databaseRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const isOwner = comment.commenter_id === ctx.userId;
|
||||
const isAdmin = ctx.privilegeLevel === "admin";
|
||||
const isAdmin = ctx.isAdmin;
|
||||
|
||||
console.log("[deleteComment] Authorization check:", {
|
||||
isOwner,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { env } from "~/env/server";
|
||||
|
||||
export const infillRouter = createTRPCRouter({
|
||||
getConfig: publicProcedure.query(({ ctx }) => {
|
||||
if (ctx.privilegeLevel !== "admin") {
|
||||
if (!ctx.isAdmin) {
|
||||
return { endpoint: null, token: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getAuthSession } from "~/server/session-helpers";
|
||||
export type Context = {
|
||||
event: APIEvent;
|
||||
userId: string | null;
|
||||
privilegeLevel: "anonymous" | "user" | "admin";
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
@@ -16,11 +16,11 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
const session = await getAuthSession(event.nativeEvent);
|
||||
|
||||
let userId: string | null = null;
|
||||
let privilegeLevel: "anonymous" | "user" | "admin" = "anonymous";
|
||||
let isAdmin = false;
|
||||
|
||||
if (session && session.userId) {
|
||||
userId = session.userId;
|
||||
privilegeLevel = session.isAdmin ? "admin" : "user";
|
||||
isAdmin = session.isAdmin;
|
||||
}
|
||||
|
||||
const req = event.nativeEvent.node?.req || event.nativeEvent;
|
||||
@@ -56,7 +56,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
||||
return {
|
||||
event,
|
||||
userId,
|
||||
privilegeLevel
|
||||
isAdmin
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const createTRPCRouter = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.userId || ctx.privilegeLevel === "anonymous") {
|
||||
if (!ctx.userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
|
||||
}
|
||||
return next({
|
||||
@@ -82,7 +82,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
});
|
||||
|
||||
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
|
||||
if (ctx.privilegeLevel !== "admin") {
|
||||
if (!ctx.isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin access required"
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getAuthSession } from "./session-helpers";
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
* Consolidates getUserID, getPrivilegeLevel, and checkAuthStatus into single function
|
||||
* @param event - H3Event
|
||||
* @returns Object with isAuthenticated, userId, and isAdmin flags
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
describe("parseConditionals", () => {
|
||||
const baseContext: ConditionalContext = {
|
||||
isAuthenticated: true,
|
||||
privilegeLevel: "user",
|
||||
isAdmin: false,
|
||||
userId: "test-user",
|
||||
currentDate: new Date("2025-06-01"),
|
||||
featureFlags: { "beta-feature": true },
|
||||
@@ -34,7 +34,7 @@ describe("parseConditionals", () => {
|
||||
const anonContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
isAuthenticated: false,
|
||||
privilegeLevel: "anonymous"
|
||||
isAdmin: false
|
||||
};
|
||||
const result = parseConditionals(html, anonContext);
|
||||
expect(result).not.toContain("Secret content");
|
||||
@@ -51,7 +51,7 @@ describe("parseConditionals", () => {
|
||||
|
||||
const adminContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
privilegeLevel: "admin"
|
||||
isAdmin: true
|
||||
};
|
||||
const adminResult = parseConditionals(html, adminContext);
|
||||
expect(adminResult).toContain("Admin panel");
|
||||
@@ -119,7 +119,7 @@ describe("parseConditionals", () => {
|
||||
const anonContext: ConditionalContext = {
|
||||
...baseContext,
|
||||
isAuthenticated: false,
|
||||
privilegeLevel: "anonymous"
|
||||
isAdmin: false
|
||||
};
|
||||
const anonResult = parseConditionals(html, anonContext);
|
||||
expect(anonResult).toContain("Not authenticated content");
|
||||
|
||||
@@ -11,7 +11,7 @@ export function getSafeEnvVariables(): Record<string, string | undefined> {
|
||||
|
||||
export interface ConditionalContext {
|
||||
isAuthenticated: boolean;
|
||||
privilegeLevel: "admin" | "user" | "anonymous";
|
||||
isAdmin: boolean;
|
||||
userId: string | null;
|
||||
currentDate: Date;
|
||||
featureFlags: Record<string, boolean>;
|
||||
@@ -194,7 +194,16 @@ function evaluatePrivilegeCondition(
|
||||
value: string,
|
||||
context: ConditionalContext
|
||||
): boolean {
|
||||
return context.privilegeLevel === value;
|
||||
switch (value) {
|
||||
case "admin":
|
||||
return context.isAdmin;
|
||||
case "user":
|
||||
return context.isAuthenticated && !context.isAdmin;
|
||||
case "anonymous":
|
||||
return !context.isAuthenticated;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
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();
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
} else {
|
||||
|
||||
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
|
||||
)
|
||||
`);
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
} else {
|
||||
|
||||
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
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE Session SET last_active_at = COALESCE(last_used, created_at) WHERE last_active_at IS NULL"
|
||||
);
|
||||
|
||||
|
||||
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
|
||||
|
||||
const usersResult = await conn.execute({
|
||||
sql: "SELECT id, email, provider, display_name, image, apple_user_string FROM User WHERE provider IS NOT NULL"
|
||||
});
|
||||
|
||||
|
||||
|
||||
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") {
|
||||
|
||||
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) {
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Step 4: Verification
|
||||
|
||||
const providerCount = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM UserProvider"
|
||||
});
|
||||
|
||||
|
||||
const multiProviderUsers = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM (
|
||||
SELECT user_id FROM UserProvider GROUP BY user_id HAVING COUNT(*) > 1
|
||||
)`
|
||||
});
|
||||
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
migratedUsers: migratedCount,
|
||||
totalProviders: (providerCount.rows[0] as any).count
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
migrateMultiAuth()
|
||||
.then((result) => {
|
||||
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -90,7 +90,6 @@ export function hashRefreshToken(token: string): string {
|
||||
* Create a new session in database and Vinxi session
|
||||
* @param event - H3Event
|
||||
* @param userId - User ID
|
||||
* @param isAdmin - Whether user is admin
|
||||
* @param rememberMe - Whether to use extended session duration
|
||||
* @param ipAddress - Client IP address
|
||||
* @param userAgent - Client user agent string
|
||||
@@ -101,7 +100,6 @@ export function hashRefreshToken(token: string): string {
|
||||
export async function createAuthSession(
|
||||
event: H3Event,
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
rememberMe: boolean,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
@@ -109,6 +107,19 @@ export async function createAuthSession(
|
||||
tokenFamily: string | null = null
|
||||
): Promise<SessionData> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Fetch is_admin from database
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT is_admin FROM User WHERE id = ?",
|
||||
args: [userId]
|
||||
});
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const isAdmin = userResult.rows[0].is_admin === 1;
|
||||
|
||||
const sessionId = uuidV4();
|
||||
const family = tokenFamily || uuidV4();
|
||||
const refreshToken = generateRefreshToken();
|
||||
@@ -374,10 +385,10 @@ async function restoreSessionFromDB(
|
||||
try {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Query DB for session with all necessary data
|
||||
// Query DB for session with all necessary data including is_admin
|
||||
const result = await conn.execute({
|
||||
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
|
||||
s.revoked, s.expires_at, u.isAdmin
|
||||
s.revoked, s.expires_at, u.is_admin
|
||||
FROM Session s
|
||||
JOIN User u ON s.user_id = u.id
|
||||
WHERE s.id = ?`,
|
||||
@@ -412,7 +423,6 @@ async function restoreSessionFromDB(
|
||||
const newSession = await createAuthSession(
|
||||
event,
|
||||
dbSession.user_id as string,
|
||||
dbSession.isAdmin === 1,
|
||||
true, // Assume rememberMe=true for restoration
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -678,7 +688,6 @@ export async function rotateAuthSession(
|
||||
const newSessionData = await createAuthSession(
|
||||
event,
|
||||
oldSessionData.userId,
|
||||
oldSessionData.isAdmin,
|
||||
oldSessionData.rememberMe,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
|
||||
Reference in New Issue
Block a user