diff --git a/.env.example b/.env.example index 267286e..8ae2ba8 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,6 @@ POSTGRES_PORT=5432 NODE_ENV=development APP_PORT=5173 +JWT_SECRET=devsecretforjwtencryptionchangeforproduction + DATABASE_URL="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:$POSTGRES_PORT/$POSTGRES_DB" diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..8e63d88 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,9 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + sessionToken?: string; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/src/lib/server/auth/__tests__/authorization.test.ts b/src/lib/server/auth/__tests__/authorization.test.ts new file mode 100644 index 0000000..bc81d06 --- /dev/null +++ b/src/lib/server/auth/__tests__/authorization.test.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect } from "vitest"; +import { AuthorizationService } from "../authorization-service"; +import { AuthenticationError } from "$lib/server/utils/errors"; +import type { JWTPayload } from "jose"; + +const mockGlobalAdmin: JWTPayload = { + userId: "global-admin-id", + email: "global@example.com", + name: "Global Admin", + role: "GLOBAL_ADMIN", + sessionId: "session-1", + iat: Date.now(), + exp: Date.now() + 3600 +}; + +const mockTenantAdmin: JWTPayload = { + userId: "tenant-admin-id", + email: "tenant@example.com", + name: "Tenant Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + sessionId: "session-2", + iat: Date.now(), + exp: Date.now() + 3600 +}; + +const mockStaff: JWTPayload = { + userId: "staff-id", + email: "staff@example.com", + name: "Staff Member", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-3", + iat: Date.now(), + exp: Date.now() + 3600 +}; + +describe("AuthorizationService", () => { + describe("requireRole", () => { + it("should allow access for correct role", () => { + expect(() => { + AuthorizationService.requireRole(mockGlobalAdmin, "GLOBAL_ADMIN"); + }).not.toThrow(); + }); + + it("should deny access for incorrect role", () => { + expect(() => { + AuthorizationService.requireRole(mockTenantAdmin, "GLOBAL_ADMIN"); + }).toThrow(AuthenticationError); + }); + + it("should deny access for null user", () => { + expect(() => { + AuthorizationService.requireRole(null as any, "GLOBAL_ADMIN"); + }).toThrow(AuthenticationError); + }); + }); + + describe("requireAnyRole", () => { + it("should allow access for any allowed role", () => { + expect(() => { + AuthorizationService.requireAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + }).not.toThrow(); + + expect(() => { + AuthorizationService.requireAnyRole(mockStaff, ["TENANT_ADMIN", "STAFF"]); + }).not.toThrow(); + }); + + it("should deny access for disallowed role", () => { + expect(() => { + AuthorizationService.requireAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + }).toThrow(AuthenticationError); + }); + }); + + describe("requireTenantAccess", () => { + it("should allow global admin access to any tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockGlobalAdmin, "any-tenant-id"); + }).not.toThrow(); + }); + + it("should allow tenant admin access to their own tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-123"); + }).not.toThrow(); + }); + + it("should deny tenant admin access to other tenants", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-456"); + }).toThrow(AuthenticationError); + }); + + it("should allow staff access to their own tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockStaff, "tenant-123"); + }).not.toThrow(); + }); + + it("should deny staff access to other tenants", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockStaff, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); + + describe("requireGlobalAdmin", () => { + it("should allow global admin access", () => { + expect(() => { + AuthorizationService.requireGlobalAdmin(mockGlobalAdmin); + }).not.toThrow(); + }); + + it("should deny non-global admin access", () => { + expect(() => { + AuthorizationService.requireGlobalAdmin(mockTenantAdmin); + }).toThrow(AuthenticationError); + }); + }); + + describe("requireTenantAdmin", () => { + it("should allow global admin access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockGlobalAdmin); + }).not.toThrow(); + }); + + it("should allow tenant admin access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin); + }).not.toThrow(); + }); + + it("should deny staff access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockStaff); + }).toThrow(AuthenticationError); + }); + + it("should check tenant access for tenant admin", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-123"); + }).not.toThrow(); + + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); + + describe("requireStaffOrAbove", () => { + it("should allow all roles access", () => { + expect(() => { + AuthorizationService.requireStaffOrAbove(mockGlobalAdmin); + }).not.toThrow(); + + expect(() => { + AuthorizationService.requireStaffOrAbove(mockTenantAdmin); + }).not.toThrow(); + + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff); + }).not.toThrow(); + }); + + it("should check tenant access for tenant-specific roles", () => { + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-123"); + }).not.toThrow(); + + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); + + describe("utility functions", () => { + it("should correctly identify user roles", () => { + expect(AuthorizationService.isGlobalAdmin(mockGlobalAdmin)).toBe(true); + expect(AuthorizationService.isGlobalAdmin(mockTenantAdmin)).toBe(false); + + expect(AuthorizationService.isTenantAdmin(mockTenantAdmin)).toBe(true); + expect(AuthorizationService.isTenantAdmin(mockStaff)).toBe(false); + + expect(AuthorizationService.isStaff(mockStaff)).toBe(true); + expect(AuthorizationService.isStaff(mockTenantAdmin)).toBe(false); + }); + + it("should correctly check role membership", () => { + expect(AuthorizationService.hasRole(mockGlobalAdmin, "GLOBAL_ADMIN")).toBe(true); + expect(AuthorizationService.hasRole(mockGlobalAdmin, "TENANT_ADMIN")).toBe(false); + + expect( + AuthorizationService.hasAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) + ).toBe(true); + expect(AuthorizationService.hasAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"])).toBe( + false + ); + }); + + it("should correctly check tenant access", () => { + expect(AuthorizationService.canAccessTenant(mockGlobalAdmin, "any-tenant")).toBe(true); + expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-123")).toBe(true); + expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-456")).toBe(false); + }); + + it("should correctly get user tenant ID", () => { + expect(AuthorizationService.getUserTenantId(mockGlobalAdmin)).toBe(null); + expect(AuthorizationService.getUserTenantId(mockTenantAdmin)).toBe("tenant-123"); + expect(AuthorizationService.getUserTenantId(mockStaff)).toBe("tenant-123"); + }); + }); +}); diff --git a/src/lib/server/auth/__tests__/jwt-utils.test.ts b/src/lib/server/auth/__tests__/jwt-utils.test.ts new file mode 100644 index 0000000..b8d98ba --- /dev/null +++ b/src/lib/server/auth/__tests__/jwt-utils.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from "vitest"; +import { + generateAccessToken, + generateRefreshToken, + verifyAccessToken, + verifyRefreshToken, + generateTokens, + isTokenExpired +} from "../jwt-utils"; +import type { SelectUser } from "$lib/server/db/central-schema"; + +const mockUser: SelectUser = { + id: "test-user-id", + email: "test@example.com", + name: "Test User", + role: "GLOBAL_ADMIN", + tenantId: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + isActive: true, + confirmed: true, + token: null, + tokenValidUntil: null +}; + +describe("JWT Utils", () => { + const sessionId = "test-session-id"; + + describe("generateAccessToken", () => { + it("should generate a valid access token", async () => { + const token = await generateAccessToken(mockUser, sessionId); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); + + it("should include user data in token payload", async () => { + const token = await generateAccessToken(mockUser, sessionId); + const payload = await verifyAccessToken(token); + + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.email).toBe(mockUser.email); + expect(payload!.name).toBe(mockUser.name); + expect(payload!.role).toBe(mockUser.role); + expect(payload!.sessionId).toBe(sessionId); + }); + }); + + describe("generateRefreshToken", () => { + it("should generate a valid refresh token", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); + + it("should include user and session data in token payload", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + const payload = await verifyRefreshToken(token); + + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.sessionId).toBe(sessionId); + }); + }); + + describe("verifyAccessToken", () => { + it("should verify a valid access token", async () => { + const token = await generateAccessToken(mockUser, sessionId); + const payload = await verifyAccessToken(token); + + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + }); + + it("should reject invalid tokens", async () => { + const payload = await verifyAccessToken("invalid.token.here"); + expect(payload).toBeNull(); + }); + + it("should reject malformed tokens", async () => { + const payload = await verifyAccessToken("invalid-token"); + expect(payload).toBeNull(); + }); + }); + + describe("verifyRefreshToken", () => { + it("should verify a valid refresh token", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + const payload = await verifyRefreshToken(token); + + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.sessionId).toBe(sessionId); + }); + + it("should reject invalid tokens", async () => { + const payload = await verifyRefreshToken("invalid.token.here"); + expect(payload).toBeNull(); + }); + + it("should reject access tokens as refresh tokens", async () => { + const accessToken = await generateAccessToken(mockUser, sessionId); + const payload = await verifyRefreshToken(accessToken); + expect(payload).toBeNull(); + }); + }); + + describe("generateTokens", () => { + it("should generate both access and refresh tokens", async () => { + const tokens = await generateTokens(mockUser, sessionId); + + expect(tokens.accessToken).toBeDefined(); + expect(tokens.refreshToken).toBeDefined(); + expect(typeof tokens.accessToken).toBe("string"); + expect(typeof tokens.refreshToken).toBe("string"); + }); + + it("should generate verifiable tokens", async () => { + const tokens = await generateTokens(mockUser, sessionId); + + const accessPayload = await verifyAccessToken(tokens.accessToken); + const refreshPayload = await verifyRefreshToken(tokens.refreshToken); + + expect(accessPayload).toBeDefined(); + expect(refreshPayload).toBeDefined(); + expect(accessPayload!.userId).toBe(mockUser.id); + expect(refreshPayload!.userId).toBe(mockUser.id); + }); + }); + + describe("isTokenExpired", () => { + it("should return false for valid tokens", async () => { + const token = await generateAccessToken(mockUser, sessionId); + expect(await isTokenExpired(token)).toBe(false); + }); + + it("should return true for malformed tokens", async () => { + expect(await isTokenExpired("invalid-token")).toBe(true); + }); + + it("should return true for empty tokens", async () => { + expect(await isTokenExpired("")).toBe(true); + }); + }); + + describe("Token expiration", () => { + it("should respect token expiration", async () => { + // Test with an obviously expired token (JWT with past exp claim) + // This is a manually crafted expired JWT for testing purposes + const expiredToken = + "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJuYW1lIjoiVGVzdCBVc2VyIiwicm9sZSI6IkdMT0JBTF9BRE1JTiIsInNlc3Npb25JZCI6InRlc3Qtc2Vzc2lvbi1pZCIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxNjAwMDAwOTAwfQ."; + + const payload = await verifyAccessToken(expiredToken); + expect(payload).toBeNull(); + }); + }); +}); diff --git a/src/lib/server/auth/authorization-service.ts b/src/lib/server/auth/authorization-service.ts new file mode 100644 index 0000000..f0a5468 --- /dev/null +++ b/src/lib/server/auth/authorization-service.ts @@ -0,0 +1,143 @@ +import type { JWTPayload } from "jose"; +import { UniversalLogger } from "$lib/logger"; +import { AuthenticationError } from "$lib/server/utils/errors"; + +const logger = new UniversalLogger().setContext("Authorization"); + +export type UserRole = "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; + +export class AuthorizationService { + static requireRole(user: JWTPayload, requiredRole: UserRole): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } + + if (user.role !== requiredRole) { + logger.warn( + `Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}` + ); + throw new AuthenticationError("Insufficient permissions"); + } + + logger.debug(`Authorization granted: User ${user.email} has required role ${requiredRole}`); + } + + static requireAnyRole(user: JWTPayload, allowedRoles: UserRole[]): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } + + if (!allowedRoles.includes(user.role as UserRole)) { + logger.warn( + `Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}` + ); + throw new AuthenticationError("Insufficient permissions"); + } + + logger.debug(`Authorization granted: User ${user.email} has allowed role ${user.role}`); + } + + static requireTenantAccess(user: JWTPayload, tenantId: string): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } + + if (user.role === "GLOBAL_ADMIN") { + logger.debug( + `Authorization granted: Global admin ${user.email} accessing tenant ${tenantId}` + ); + return; + } + + if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { + if (!user.tenantId) { + logger.warn(`Access denied: User ${user.email} has no tenant assigned`); + throw new AuthenticationError("No tenant access"); + } + + if (user.tenantId !== tenantId) { + logger.warn( + `Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}` + ); + throw new AuthenticationError("Tenant access denied"); + } + + logger.debug(`Authorization granted: User ${user.email} accessing own tenant ${tenantId}`); + return; + } + + logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`); + throw new AuthenticationError("Invalid role"); + } + + static requireGlobalAdmin(user: JWTPayload): void { + this.requireRole(user, "GLOBAL_ADMIN"); + } + + static requireTenantAdmin(user: JWTPayload, tenantId?: string): void { + this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + + if (tenantId && user.role === "TENANT_ADMIN") { + this.requireTenantAccess(user, tenantId); + } + } + + static requireStaffOrAbove(user: JWTPayload, tenantId?: string): void { + this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]); + + if (tenantId && (user.role === "TENANT_ADMIN" || user.role === "STAFF")) { + this.requireTenantAccess(user, tenantId); + } + } + + static canAccessTenant(user: JWTPayload, tenantId: string): boolean { + try { + this.requireTenantAccess(user, tenantId); + return true; + } catch { + return false; + } + } + + static isGlobalAdmin(user: JWTPayload): boolean { + return user.role === "GLOBAL_ADMIN"; + } + + static isTenantAdmin(user: JWTPayload): boolean { + return user.role === "TENANT_ADMIN"; + } + + static isStaff(user: JWTPayload): boolean { + return user.role === "STAFF"; + } + + static hasRole(user: JWTPayload, role: UserRole): boolean { + return user.role === role; + } + + static hasAnyRole(user: JWTPayload, roles: UserRole[]): boolean { + return roles.includes(user.role as UserRole); + } + + static getUserTenantId(user: JWTPayload): string | null { + return (user.tenantId as string) || null; + } +} + +export function withAuthorization( + user: JWTPayload, + requiredRole?: UserRole, + allowedRoles?: UserRole[] +) { + if (!user) { + throw new AuthenticationError("Authentication required"); + } + + if (requiredRole) { + AuthorizationService.requireRole(user, requiredRole); + } + + if (allowedRoles) { + AuthorizationService.requireAnyRole(user, allowedRoles); + } +} diff --git a/src/lib/server/auth/jwt-utils.ts b/src/lib/server/auth/jwt-utils.ts new file mode 100644 index 0000000..47cf1a0 --- /dev/null +++ b/src/lib/server/auth/jwt-utils.ts @@ -0,0 +1,122 @@ +import { SignJWT, jwtVerify, type JWTPayload } from "jose"; +import { env } from "$env/dynamic/private"; +import type { SelectUser } from "$lib/server/db/central-schema"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("JWT"); + +export interface JWTTokens { + accessToken: string; + refreshToken: string; +} + +const JWT_SECRET = new TextEncoder().encode( + env.JWT_SECRET || "dev-secret-key-change-in-production-must-be-32-chars-minimum" +); +const ACCESS_TOKEN_EXPIRES = "15m"; // 15 minutes +const REFRESH_TOKEN_EXPIRES = "7d"; // 7 days + +export async function generateAccessToken(user: SelectUser, sessionId: string): Promise { + const now = Math.floor(Date.now() / 1000); + + const payload: Omit = { + userId: user.id, + email: user.email, + name: user.name, + role: user.role, + tenantId: user.tenantId || undefined, + sessionId + }; + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(ACCESS_TOKEN_EXPIRES) + .sign(JWT_SECRET); + + return jwt; +} + +export async function generateRefreshToken(userId: string, sessionId: string): Promise { + const now = Math.floor(Date.now() / 1000); + + const payload = { + userId, + sessionId, + type: "refresh" + }; + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(REFRESH_TOKEN_EXPIRES) + .sign(JWT_SECRET); + + return jwt; +} + +export async function verifyAccessToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + return { + userId: payload.userId, + email: payload.email, + name: payload.name, + role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", + tenantId: payload.tenantId as string | undefined, + sessionId: payload.sessionId, + iat: payload.iat, + exp: payload.exp + }; + } catch (error) { + logger.warn("JWT verification failed:", { error: String(error) }); + return null; + } +} + +export async function verifyRefreshToken( + token: string +): Promise<{ userId: string; sessionId: string } | null> { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if ( + typeof payload.userId === "string" && + typeof payload.sessionId === "string" && + payload.type === "refresh" + ) { + return { + userId: payload.userId, + sessionId: payload.sessionId + }; + } + + logger.warn("Invalid refresh token payload"); + return null; + } catch (error) { + logger.warn("Refresh token verification failed:", { error: String(error) }); + return null; + } +} + +export async function generateTokens(user: SelectUser, sessionId: string): Promise { + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(user, sessionId), + generateRefreshToken(user.id, sessionId) + ]); + + return { + accessToken, + refreshToken + }; +} + +export async function isTokenExpired(token: string): Promise { + try { + await jwtVerify(token, JWT_SECRET); + return false; + } catch { + return true; + } +} diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts new file mode 100644 index 0000000..d6722ce --- /dev/null +++ b/src/lib/server/auth/session-service.ts @@ -0,0 +1,244 @@ +import { eq, and, gt, lt } from "drizzle-orm"; +import { centralDb } from "$lib/server/db"; +import { user, userSession } from "$lib/server/db/central-schema"; +import type { + SelectUser, + InsertUserSession, + SelectUserSession +} from "$lib/server/db/central-schema"; +import { generateTokens, verifyRefreshToken, isTokenExpired } from "./jwt-utils"; +import { UniversalLogger } from "$lib/logger"; +import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { uuidv7 } from "uuidv7"; + +const logger = new UniversalLogger().setContext("AuthService"); + +export interface SessionData { + sessionToken: string; + accessToken: string; + refreshToken: string; + user: SelectUser; + expiresAt: Date; +} + +export interface LoginResult { + sessionToken: string; + accessToken: string; + refreshToken: string; + user: SelectUser; + expiresAt: Date; +} + +export interface RefreshResult { + accessToken: string; + refreshToken: string; + expiresAt: Date; +} + +export class SessionService { + private static readonly SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + + static async createSession( + userId: string, + ipAddress?: string, + userAgent?: string + ): Promise { + logger.info(`Creating session for user: ${userId}`); + + const existingUser = await centralDb.select().from(user).where(eq(user.id, userId)).limit(1); + + if (existingUser.length === 0) { + throw new NotFoundError(`User with ID ${userId} not found`); + } + + const userData = existingUser[0]; + + if (!userData.isActive) { + throw new ValidationError("User account is inactive"); + } + + if (!userData.confirmed) { + throw new ValidationError("User account is not confirmed"); + } + + const sessionId = uuidv7(); + const sessionToken = uuidv7(); + const expiresAt = new Date(Date.now() + this.SESSION_DURATION); + + const tokens = await generateTokens(userData, sessionId); + + const sessionData: InsertUserSession = { + id: sessionId, + userId: userData.id, + sessionToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ipAddress, + userAgent, + expiresAt, + lastUsedAt: new Date() + }; + + await centralDb.insert(userSession).values(sessionData); + + await centralDb.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId)); + + logger.info(`Session created successfully for user: ${userId}`); + + return { + sessionToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + user: userData, + expiresAt + }; + } + + static async validateSession(sessionToken: string): Promise { + logger.debug(`Validating session: ${sessionToken}`); + + const sessions = await centralDb + .select() + .from(userSession) + .innerJoin(user, eq(userSession.userId, user.id)) + .where(and(eq(userSession.sessionToken, sessionToken), gt(userSession.expiresAt, new Date()))) + .limit(1); + + if (sessions.length === 0) { + logger.warn(`Session not found or expired: ${sessionToken}`); + return null; + } + + const session = sessions[0]; + + if (!session.user.isActive) { + logger.warn(`User account inactive for session: ${sessionToken}`); + return null; + } + + if (await isTokenExpired(session.user_session.accessToken)) { + logger.debug(`Access token expired for session: ${sessionToken}`); + return null; + } + + await centralDb + .update(userSession) + .set({ lastUsedAt: new Date() }) + .where(eq(userSession.id, session.user_session.id)); + + logger.debug(`Session validated successfully: ${sessionToken}`); + + return { + sessionToken: session.user_session.sessionToken, + accessToken: session.user_session.accessToken, + refreshToken: session.user_session.refreshToken, + user: session.user, + expiresAt: session.user_session.expiresAt + }; + } + + static async refreshSession(refreshToken: string): Promise { + logger.debug("Refreshing session tokens"); + + const tokenData = await verifyRefreshToken(refreshToken); + if (!tokenData) { + logger.warn("Invalid refresh token"); + return null; + } + + const sessions = await centralDb + .select() + .from(userSession) + .innerJoin(user, eq(userSession.userId, user.id)) + .where( + and( + eq(userSession.id, tokenData.sessionId), + eq(userSession.refreshToken, refreshToken), + gt(userSession.expiresAt, new Date()) + ) + ) + .limit(1); + + if (sessions.length === 0) { + logger.warn("Session not found for refresh token"); + return null; + } + + const session = sessions[0]; + + if (!session.user.isActive) { + logger.warn("User account inactive for refresh"); + return null; + } + + const newTokens = await generateTokens(session.user, session.user_session.id); + const newExpiresAt = new Date(Date.now() + this.SESSION_DURATION); + + await centralDb + .update(userSession) + .set({ + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + expiresAt: newExpiresAt, + lastUsedAt: new Date() + }) + .where(eq(userSession.id, session.user_session.id)); + + logger.info("Session tokens refreshed successfully"); + + return { + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + expiresAt: newExpiresAt + }; + } + + static async logout(sessionToken: string): Promise { + logger.info(`Logging out session: ${sessionToken}`); + + await centralDb.delete(userSession).where(eq(userSession.sessionToken, sessionToken)); + + logger.info(`Session logged out successfully: ${sessionToken}`); + } + + static async logoutAllSessions(userId: string): Promise { + logger.info(`Logging out all sessions for user: ${userId}`); + + await centralDb.delete(userSession).where(eq(userSession.userId, userId)); + + logger.info(`All sessions logged out for user: ${userId}`); + } + + static async cleanupExpiredSessions(): Promise { + logger.info("Cleaning up expired sessions"); + + await centralDb.delete(userSession).where(lt(userSession.expiresAt, new Date())); + + logger.info("Expired sessions cleaned up"); + } + + static async getActiveSessions(userId: string): Promise { + logger.debug(`Getting active sessions for user: ${userId}`); + + const sessions = await centralDb + .select() + .from(userSession) + .where(and(eq(userSession.userId, userId), gt(userSession.expiresAt, new Date()))) + .orderBy(userSession.lastUsedAt); + + return sessions; + } + + static async getUserFromSession(sessionToken: string): Promise { + const sessionData = await this.validateSession(sessionToken); + return sessionData ? sessionData.user : null; + } + + static async revokeSession(sessionId: string): Promise { + logger.info(`Revoking session: ${sessionId}`); + + await centralDb.delete(userSession).where(eq(userSession.id, sessionId)); + + logger.info(`Session revoked: ${sessionId}`); + } +} diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts new file mode 100644 index 0000000..b459156 --- /dev/null +++ b/src/lib/server/auth/webauthn-service.ts @@ -0,0 +1,222 @@ +import { centralDb } from "$lib/server/db"; +import { userPasskey } from "$lib/server/db/central-schema"; +import { eq } from "drizzle-orm"; +import { createHash } from "node:crypto"; +import { UniversalLogger } from "$lib/logger"; +import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; + +const logger = new UniversalLogger().setContext("WebAuthnService"); + +export interface WebAuthnCredential { + id: string; + response: { + authenticatorData: string; + signature: string; + userHandle?: string; + clientDataJSON: string; + }; +} + +export interface WebAuthnVerificationResult { + verified: boolean; + userId?: string; + newCounter?: number; + passkeyId?: string; +} + +export class WebAuthnService { + /** + * Verify a WebAuthn authentication assertion + * @param credential - The WebAuthn credential from the client + * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) + * @returns Verification result with user ID if successful + */ + static async verifyAuthentication( + credential: WebAuthnCredential, + challengeFromSession: string + ): Promise { + try { + logger.debug("Verifying WebAuthn authentication", { + credentialId: credential.id, + hasChallenge: !!challengeFromSession + }); + + // Get the passkey from database + const passkeys = await centralDb + .select() + .from(userPasskey) + .where(eq(userPasskey.id, credential.id)) + .limit(1); + + if (passkeys.length === 0) { + logger.warn("Passkey not found", { credentialId: credential.id }); + return { verified: false }; + } + + const passkey = passkeys[0]; + + // Parse client data JSON + const clientDataJSON = JSON.parse( + Buffer.from(credential.response.clientDataJSON, "base64").toString() + ); + + // Verify the challenge (convert base64url to base64 for comparison) + const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); + const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); + + if (expectedChallenge !== receivedChallenge) { + logger.warn("Challenge mismatch", { + credentialId: credential.id, + expectedChallenge: expectedChallenge.substring(0, 8) + "...", + receivedChallenge: receivedChallenge.substring(0, 8) + "..." + }); + return { verified: false }; + } + + // Verify the origin (in production, this should match your domain) + // For now, we'll skip this check as it depends on your deployment configuration + logger.debug("Origin verification skipped (implement for production)", { + origin: clientDataJSON.origin + }); + + // Parse authenticator data + const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); + + // Extract counter from authenticator data (bytes 33-36) + const newCounter = authenticatorDataBuffer.readUInt32BE(33); + + // Verify counter is greater than stored counter (prevents replay attacks) + if (newCounter <= passkey.counter) { + logger.warn("Counter verification failed - possible replay attack", { + credentialId: credential.id, + storedCounter: passkey.counter, + newCounter + }); + return { verified: false }; + } + + // Create the data to be signed + const clientDataHash = createHash("sha256") + .update(Buffer.from(credential.response.clientDataJSON, "base64")) + .digest(); + + const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); + + // Verify the signature + const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); + const signatureBuffer = Buffer.from(credential.response.signature, "base64"); + + const isSignatureValid = await this.verifySignature( + publicKeyBuffer, + signedData, + signatureBuffer + ); + + if (!isSignatureValid) { + logger.warn("Signature verification failed", { credentialId: credential.id }); + return { verified: false }; + } + + logger.debug("WebAuthn authentication successful", { + credentialId: credential.id, + userId: passkey.userId, + newCounter + }); + + return { + verified: true, + userId: passkey.userId, + newCounter, + passkeyId: passkey.id + }; + } catch (error) { + logger.error("WebAuthn verification error", { + error: String(error), + credentialId: credential.id + }); + return { verified: false }; + } + } + + /** + * Verify a signature using the stored public key + * This is a simplified implementation - in production, you should use a proper WebAuthn library + * like @simplewebauthn/server for complete verification + */ + private static async verifySignature( + publicKey: Buffer, + signedData: Buffer, + signature: Buffer + ): Promise { + try { + const crypto = await import("node:crypto"); + + // This is a simplified implementation + // In production, you should use a proper WebAuthn library that handles: + // - Different key formats (COSE, etc.) + // - Different signature algorithms + // - Proper ASN.1 parsing + // - Certificate chain validation + + // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) + // and that the public key is in the correct format + + const verify = crypto.createVerify("SHA256"); + verify.update(signedData); + verify.end(); + + // This is a placeholder - proper implementation would: + // 1. Parse the COSE key format + // 2. Convert to the correct format for Node.js crypto + // 3. Handle different algorithms properly + + logger.debug("Signature verification (simplified implementation)", { + publicKeyLength: publicKey.length, + signatureLength: signature.length, + signedDataLength: signedData.length + }); + + // For development, we'll return true if all data is present + // TODO: Replace with proper signature verification + return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; + } catch (error) { + logger.error("Signature verification error", { error: String(error) }); + return false; + } + } + + /** + * Generate a random challenge for WebAuthn authentication + * This should be stored in the session and used for verification + */ + static generateChallenge(): string { + const crypto = require("node:crypto"); + return crypto.randomBytes(32).toString("base64url"); + } + + /** + * Update the counter for a passkey after successful authentication + */ + static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise { + await centralDb + .update(userPasskey) + .set({ + counter: newCounter, + lastUsedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(userPasskey.id, passkeyId)); + + logger.debug("Passkey counter updated", { passkeyId, newCounter }); + } + + /** + * Get all passkeys for a user + */ + static async getUserPasskeys(userId: string) { + return await centralDb + .select() + .from(userPasskey) + .where(eq(userPasskey.userId, userId)); + } +} \ No newline at end of file diff --git a/src/lib/server/services/admin-account-service.ts b/src/lib/server/services/admin-account-service.ts deleted file mode 100644 index 44e587e..0000000 --- a/src/lib/server/services/admin-account-service.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { centralDb } from "../db"; -import * as centralSchema from "../db/central-schema"; -import { eq, desc, gt, and } from "drizzle-orm"; -import type { InferInsertModel } from "drizzle-orm"; -import z from "zod/v4"; -import { NotFoundError, ValidationError } from "../utils/errors"; -import { uuidv7 } from "uuidv7"; -import { addMinutes } from "date-fns"; -import logger from "$lib/logger"; - -export type InsertAdmin = InferInsertModel; -export type InsertAdminPasskey = InferInsertModel; - -const adminCreationSchema = z.object({ - name: z.string().min(5), - email: z.email(), - token: z.uuidv7().optional(), - tokenValidUntil: z.date().optional() -}); - -type AdminCreation = z.infer; - -export class AdminAccountService { - /** - * Create a new admin user - */ - static async createAdmin(adminData: AdminCreation) { - const log = logger.setContext("AdminAccountService"); - log.debug("Creating new admin account", { email: adminData.email, name: adminData.name }); - - const validated = adminCreationSchema.safeParse(adminData); - if (!validated.success) { - log.warn("Admin creation failed: Invalid data", { - email: adminData.email, - errors: validated.error - }); - throw new ValidationError("Invalid admin data"); - } - - adminData.token = uuidv7(); - adminData.tokenValidUntil = addMinutes(new Date(), 10); - - try { - const result = await centralDb - .insert(centralSchema.admin) - .values({ ...adminData, confirmed: false, isActive: false }) - .returning(); - - log.debug("Admin account created successfully", { - adminId: result[0].id, - email: result[0].email, - tokenValidUntil: result[0].tokenValidUntil - }); - - // TODO: Send confirmation email to admin (Link contains the above token) - return result[0]; - } catch (error) { - log.error("Failed to create admin account", { email: adminData.email, error: String(error) }); - throw error; - } - } - - /** - * Resend the confirmation email for an admin - * @param email - Email of the admin to confirm - */ - static async resendConfirmationEmail(email: string): Promise { - const log = logger.setContext("AdminAccountService"); - log.debug("Resending confirmation email", { email }); - - const token = uuidv7(); - const tokenValidUntil = addMinutes(new Date(), 10); - - try { - const result = await centralDb - .update(centralSchema.admin) - .set({ token, tokenValidUntil }) - .execute(); - - if (result.count != 1) { - log.warn("Failed to resend confirmation email: Admin not found", { email }); - throw new NotFoundError(`Could not resend confirmation mail for unknown admin ${email}`); - } - - log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); - // TODO: Send confirmation email - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to resend confirmation email", { email, error: String(error) }); - throw error; - } - } - - /** - * Confirm and active admin after confirmation link was clicked - * @param linkToken - The token from the link - */ - static async confirm(linkToken: string): Promise { - const log = logger.setContext("AdminAccountService"); - log.debug("Confirming admin account", { token: linkToken.substring(0, 8) + "..." }); - - try { - const result = await centralDb - .update(centralSchema.admin) - .set({ confirmed: true, isActive: true }) - .where( - and( - eq(centralSchema.admin.token, linkToken), - gt(centralSchema.admin.tokenValidUntil, new Date()) - ) - ) - .execute(); - - if (result.count != 1) { - log.warn("Admin confirmation failed: Invalid or expired token", { - token: linkToken.substring(0, 8) + "..." - }); - throw new NotFoundError("Invalid or timed-out token"); - } - - log.debug("Admin account confirmed successfully", { - token: linkToken.substring(0, 8) + "..." - }); - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to confirm admin account", { - token: linkToken.substring(0, 8) + "...", - error: String(error) - }); - throw error; - } - } - - /** - * Get admin by email - */ - static async getAdminByEmail(email: string) { - const log = logger.setContext("AdminAccountService"); - log.debug("Getting admin by email", { email }); - - try { - const result = await centralDb - .select() - .from(centralSchema.admin) - .where(eq(centralSchema.admin.email, email)) - .limit(1); - - if (!result[0]) { - log.warn("Admin not found by email", { email }); - throw new NotFoundError(`No admin account for ${email}.`); - } - - log.debug("Admin found by email", { - email, - adminId: result[0].id, - confirmed: result[0].confirmed - }); - return result[0]; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to get admin by email", { email, error: String(error) }); - throw error; - } - } - - /** - * Get all admins - */ - static async getAllAdmins() { - const log = logger.setContext("AdminAccountService"); - log.debug("Getting all admins"); - - try { - const result = await centralDb - .select() - .from(centralSchema.admin) - .orderBy(desc(centralSchema.admin.createdAt)); - - log.debug("Retrieved all admins", { count: result.length }); - return result; - } catch (error) { - log.error("Failed to get all admins", { error: String(error) }); - throw error; - } - } - - /** - * Update admin data - */ - static async updateAdmin( - adminId: string, - updateData: Partial> - ) { - const log = logger.setContext("AdminAccountService"); - log.debug("Updating admin", { adminId, updateFields: Object.keys(updateData) }); - - try { - const result = await centralDb - .update(centralSchema.admin) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.admin.id, adminId)) - .returning(); - - if (result[0]) { - log.debug("Admin updated successfully", { adminId, updateFields: Object.keys(updateData) }); - } else { - log.warn("Admin update failed: Admin not found", { adminId }); - } - - return result[0] || null; - } catch (error) { - log.error("Failed to update admin", { adminId, error: String(error) }); - throw error; - } - } - - /** - * Permanently delete admin and all associated passkeys - */ - static async deleteAdmin(adminId: string) { - const log = logger.setContext("AdminAccountService"); - log.debug("Deleting admin and associated passkeys", { adminId }); - - try { - // Delete associated passkeys first - const passkeyResult = await centralDb - .delete(centralSchema.adminPasskey) - .where(eq(centralSchema.adminPasskey.adminId, adminId)); - - log.debug("Deleted admin passkeys", { adminId, deletedCount: passkeyResult.count || 0 }); - - // Delete admin - const result = await centralDb - .delete(centralSchema.admin) - .where(eq(centralSchema.admin.id, adminId)) - .returning(); - - if (result[0]) { - log.debug("Admin deleted successfully", { adminId, email: result[0].email }); - } else { - log.warn("Admin deletion failed: Admin not found", { adminId }); - } - - return result[0] || null; - } catch (error) { - log.error("Failed to delete admin", { adminId, error: String(error) }); - throw error; - } - } - - /** - * Update last login timestamp - */ - static async updateLastLogin(adminId: string) { - const log = logger.setContext("AdminAccountService"); - log.debug("Updating last login timestamp", { adminId }); - - return await this.updateAdmin(adminId, { lastLoginAt: new Date() }); - } - - /** - * Add a passkey for an admin - */ - static async addPasskey( - adminId: string, - passkeyData: Omit - ) { - const log = logger.setContext("AdminAccountService"); - log.debug("Adding passkey for admin", { - adminId, - passkeyId: passkeyData.id, - deviceName: passkeyData.deviceName - }); - - try { - const result = await centralDb - .insert(centralSchema.adminPasskey) - .values({ - ...passkeyData, - adminId - }) - .returning(); - - log.debug("Passkey added successfully", { - adminId, - passkeyId: result[0].id, - deviceName: result[0].deviceName - }); - return result[0]; - } catch (error) { - log.error("Failed to add passkey", { - adminId, - passkeyId: passkeyData.id, - error: String(error) - }); - throw error; - } - } - - /** - * Get all passkeys for an admin - */ - static async getAdminPasskeys(adminId: string) { - return await centralDb - .select() - .from(centralSchema.adminPasskey) - .where(eq(centralSchema.adminPasskey.adminId, adminId)) - .orderBy(desc(centralSchema.adminPasskey.createdAt)); - } - - /** - * Update passkey (e.g., counter, last used time) - */ - static async updatePasskey( - passkeyId: string, - updateData: Partial> - ) { - const result = await centralDb - .update(centralSchema.adminPasskey) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.adminPasskey.id, passkeyId)) - .returning(); - - return result[0] || null; - } - - /** - * Delete a passkey - */ - static async deletePasskey(passkeyId: string) { - const result = await centralDb - .delete(centralSchema.adminPasskey) - .where(eq(centralSchema.adminPasskey.id, passkeyId)) - .returning(); - - return result[0] || null; - } - - /** - * Update passkey last used timestamp and counter - */ - static async updatePasskeyUsage(passkeyId: string, newCounter: number) { - const log = logger.setContext("AdminAccountService"); - log.debug("Updating passkey usage", { passkeyId, newCounter }); - - return await this.updatePasskey(passkeyId, { - counter: newCounter, - lastUsedAt: new Date() - }); - } - - /** - * Check if any admin exists in the system - */ - static async adminExists(): Promise { - const log = logger.setContext("AdminAccountService"); - log.debug("Checking if any admin exists"); - - try { - const result = await centralDb - .select({ id: centralSchema.admin.id }) - .from(centralSchema.admin) - .limit(1); - - const exists = result.length > 0; - log.debug("Admin existence check completed", { exists }); - return exists; - } catch (error) { - log.error("Failed to check admin existence", { error: String(error) }); - throw error; - } - } - - /** - * Get total count of admins in the system - */ - static async getAdminCount(): Promise { - const log = logger.setContext("AdminAccountService"); - log.debug("Getting admin count"); - - try { - const result = await centralDb - .select() - .from(centralSchema.admin); - - const count = result.length; - log.debug("Admin count retrieved", { count }); - return count; - } catch (error) { - log.error("Failed to get admin count", { error: String(error) }); - throw error; - } - } -} diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts new file mode 100644 index 0000000..e748d4e --- /dev/null +++ b/src/lib/server/services/user-service.ts @@ -0,0 +1,527 @@ +import { centralDb } from "../db"; +import * as centralSchema from "../db/central-schema"; +import { eq, desc, gt, and } from "drizzle-orm"; +import type { InferInsertModel } from "drizzle-orm"; +import z from "zod/v4"; +import { NotFoundError, ValidationError } from "../utils/errors"; +import { uuidv7 } from "uuidv7"; +import { addMinutes } from "date-fns"; +import logger from "$lib/logger"; +import { + generateRecoveryPassphrase, + hashPassphrase, + validatePassphraseStrength +} from "../utils/passphrase"; + +export type InsertUser = InferInsertModel; +export type InsertUserPasskey = InferInsertModel; + +const userCreationSchema = z.object({ + name: z.string().min(5), + email: z.email(), + role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(), + tenantId: z.string().uuid().optional(), + passphrase: z.string().min(12).optional(), + token: z.uuidv7().optional(), + tokenValidUntil: z.date().optional() +}); + +type UserCreation = z.infer; + +export class UserService { + /** + * Create a new user + */ + static async createUser(userData: UserCreation) { + const log = logger.setContext("UserService"); + log.debug("Creating new user account", { + email: userData.email, + name: userData.name, + role: userData.role, + hasPassphrase: !!userData.passphrase + }); + + const validated = userCreationSchema.safeParse(userData); + if (!validated.success) { + log.warn("User creation failed: Invalid data", { + email: userData.email, + errors: validated.error + }); + throw new ValidationError("Invalid user data"); + } + + // Validate passphrase strength if provided + if (userData.passphrase && !validatePassphraseStrength(userData.passphrase)) { + throw new ValidationError("Passphrase must be at least 12 characters long"); + } + + userData.token = uuidv7(); + userData.tokenValidUntil = addMinutes(new Date(), 10); + + // Prepare user data for database + const userDataForDb: InsertUser = { + name: userData.name, + email: userData.email, + role: userData.role, + tenantId: userData.tenantId, + token: userData.token, + tokenValidUntil: userData.tokenValidUntil, + confirmed: false, + isActive: false + }; + + // Handle passphrase or generate recovery passphrase + if (userData.passphrase) { + // User provided a passphrase, hash it + userDataForDb.passphraseHash = await hashPassphrase(userData.passphrase); + } else if (userData.role === "GLOBAL_ADMIN") { + // No passphrase provided, generate a recovery passphrase + userDataForDb.recoveryPassphrase = generateRecoveryPassphrase(); + } + + try { + const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning(); + + log.debug("User account created successfully", { + userId: result[0].id, + email: result[0].email, + tokenValidUntil: result[0].tokenValidUntil, + hasPassphrase: !!result[0].passphraseHash, + hasRecoveryPassphrase: !!result[0].recoveryPassphrase + }); + + // TODO: Send confirmation email to user (Link contains the above token) + return result[0]; + } catch (error) { + log.error("Failed to create user account", { email: userData.email, error: String(error) }); + throw error; + } + } + + /** + * Resend the confirmation email for a user + * @param email - Email of the user to confirm + */ + static async resendConfirmationEmail(email: string): Promise { + const log = logger.setContext("UserService"); + log.debug("Resending confirmation email", { email }); + + const token = uuidv7(); + const tokenValidUntil = addMinutes(new Date(), 10); + + try { + const result = await centralDb + .update(centralSchema.user) + .set({ token, tokenValidUntil }) + .execute(); + + if (result.count != 1) { + log.warn("Failed to resend confirmation email: User not found", { email }); + throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`); + } + + log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); + // TODO: Send confirmation email + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to resend confirmation email", { email, error: String(error) }); + throw error; + } + } + + /** + * Confirm and activate user after confirmation link was clicked + * @param linkToken - The token from the link + */ + static async confirm(linkToken: string): Promise<{ recoveryPassphrase?: string }> { + const log = logger.setContext("UserService"); + log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); + + try { + // First, get the user data to check for recovery passphrase + const userData = await centralDb + .select({ + id: centralSchema.user.id, + recoveryPassphrase: centralSchema.user.recoveryPassphrase + }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.token, linkToken), + gt(centralSchema.user.tokenValidUntil, new Date()) + ) + ) + .limit(1); + + if (userData.length === 0) { + log.warn("User confirmation failed: Invalid or expired token", { + token: linkToken.substring(0, 8) + "..." + }); + throw new NotFoundError("Invalid or timed-out token"); + } + + const user = userData[0]; + + // Update the user to confirmed and active, and clear the recovery passphrase + const result = await centralDb + .update(centralSchema.user) + .set({ + confirmed: true, + isActive: true, + recoveryPassphrase: null // Clear it after showing it once + }) + .where(eq(centralSchema.user.id, user.id)) + .execute(); + + if (result.count != 1) { + throw new NotFoundError("Failed to confirm user"); + } + + log.debug("User account confirmed successfully", { + userId: user.id, + token: linkToken.substring(0, 8) + "...", + hadRecoveryPassphrase: !!user.recoveryPassphrase + }); + + return { + recoveryPassphrase: user.recoveryPassphrase || undefined + }; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to confirm user account", { + token: linkToken.substring(0, 8) + "...", + error: String(error) + }); + throw error; + } + } + + /** + * Add additional WebAuthn passkey to existing user + */ + static async addAdditionalPasskey(userId: string, passkeyData: InsertUserPasskey): Promise { + const log = logger.setContext("UserService"); + log.debug("Adding additional passkey to user", { userId, passkeyId: passkeyData.id }); + + try { + // Check if user exists and is active + const user = await centralDb + .select({ id: centralSchema.user.id, confirmed: centralSchema.user.confirmed }) + .from(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .limit(1); + + if (user.length === 0) { + throw new NotFoundError("User not found"); + } + + if (!user[0].confirmed) { + throw new ValidationError( + "User account must be confirmed before adding additional passkeys" + ); + } + + // Add the passkey + await centralDb.insert(centralSchema.userPasskey).values({ + ...passkeyData, + userId, + createdAt: new Date(), + updatedAt: new Date() + }); + + log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id }); + } catch (error) { + if (error instanceof NotFoundError || error instanceof ValidationError) throw error; + log.error("Failed to add additional passkey", { userId, error: String(error) }); + throw error; + } + } + + /** + * Get admin by email + */ + static async getUserByEmail(email: string) { + const log = logger.setContext("UserService"); + log.debug("Getting admin by email", { email }); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .where(eq(centralSchema.user.email, email)) + .limit(1); + + if (!result[0]) { + log.warn("User not found by email", { email }); + throw new NotFoundError(`No user account for ${email}.`); + } + + log.debug("User found by email", { + email, + userId: result[0].id, + confirmed: result[0].confirmed + }); + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to get user by email", { email, error: String(error) }); + throw error; + } + } + + /** + * Get all admins + */ + static async getAllAdmins() { + const log = logger.setContext("UserService"); + log.debug("Getting all admins"); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .orderBy(desc(centralSchema.user.createdAt)) + .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")); + + log.debug("Retrieved all admins", { count: result.length }); + return result; + } catch (error) { + log.error("Failed to get all admins", { error: String(error) }); + throw error; + } + } + + /** + * Get all admins + */ + static async getAllUsers() { + const log = logger.setContext("UserService"); + log.debug("Getting all users"); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .orderBy(desc(centralSchema.user.createdAt)); + + log.debug("Retrieved all users", { count: result.length }); + return result; + } catch (error) { + log.error("Failed to get all users", { error: String(error) }); + throw error; + } + } + + /** + * Update a user's data + */ + static async updateUser( + userId: string, + updateData: Partial> + ) { + const log = logger.setContext("UserService"); + log.debug("Updating user", { userId, updateFields: Object.keys(updateData) }); + + try { + const result = await centralDb + .update(centralSchema.user) + .set({ + ...updateData, + updatedAt: new Date() + }) + .where(eq(centralSchema.user.id, userId)) + .returning(); + + if (result[0]) { + log.debug("User updated successfully", { userId, updateFields: Object.keys(updateData) }); + } else { + log.warn("User update failed: User not found", { userId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to update user", { userId, error: String(error) }); + throw error; + } + } + + /** + * Permanently delete admin and all associated passkeys + */ + static async deleteUser(userId: string) { + const log = logger.setContext("UserService"); + log.debug("Deleting user and associated passkeys", { userId }); + + try { + // Delete associated passkeys first + const passkeyResult = await centralDb + .delete(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.userId, userId)); + + log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 }); + + // Delete admin + const result = await centralDb + .delete(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .returning(); + + if (result[0]) { + log.debug("User deleted successfully", { userId, email: result[0].email }); + } else { + log.warn("User deletion failed: User not found", { userId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to delete user", { userId, error: String(error) }); + throw error; + } + } + + /** + * Update last login timestamp + */ + static async updateLastLogin(userId: string) { + const log = logger.setContext("UserService"); + log.debug("Updating last login timestamp", { userId }); + + return await this.updateUser(userId, { lastLoginAt: new Date() }); + } + + /** + * Add a passkey for a uaer + */ + static async addPasskey( + userId: string, + passkeyData: Omit + ) { + const log = logger.setContext("UserService"); + log.debug("Adding passkey for user", { + userId, + passkeyId: passkeyData.id, + deviceName: passkeyData.deviceName + }); + + try { + const result = await centralDb + .insert(centralSchema.userPasskey) + .values({ + ...passkeyData, + userId + }) + .returning(); + + log.debug("Passkey added successfully", { + userId, + passkeyId: result[0].id, + deviceName: result[0].deviceName + }); + return result[0]; + } catch (error) { + log.error("Failed to add passkey", { + userId, + passkeyId: passkeyData.id, + error: String(error) + }); + throw error; + } + } + + /** + * Get all passkeys for an admin + */ + static async getUserPasskeys(userId: string) { + return await centralDb + .select() + .from(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.userId, userId)) + .orderBy(desc(centralSchema.userPasskey.createdAt)); + } + + /** + * Update passkey (e.g., counter, last used time) + */ + static async updatePasskey( + passkeyId: string, + updateData: Partial> + ) { + const result = await centralDb + .update(centralSchema.userPasskey) + .set({ + ...updateData, + updatedAt: new Date() + }) + .where(eq(centralSchema.userPasskey.id, passkeyId)) + .returning(); + + return result[0] || null; + } + + /** + * Delete a passkey + */ + static async deletePasskey(passkeyId: string) { + const result = await centralDb + .delete(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.id, passkeyId)) + .returning(); + + return result[0] || null; + } + + /** + * Update passkey last used timestamp and counter + */ + static async updatePasskeyUsage(passkeyId: string, newCounter: number) { + const log = logger.setContext("UserService"); + log.debug("Updating passkey usage", { passkeyId, newCounter }); + + return await this.updatePasskey(passkeyId, { + counter: newCounter, + lastUsedAt: new Date() + }); + } + + /** + * Check if any admin exists in the system + */ + static async adminExists(): Promise { + const log = logger.setContext("UserService"); + log.debug("Checking if any admin exists"); + + try { + const result = await centralDb + .select({ id: centralSchema.user.id }) + .from(centralSchema.user) + .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")) + .limit(1); + + const exists = result.length > 0; + log.debug("Admin existence check completed", { exists }); + return exists; + } catch (error) { + log.error("Failed to check admin existence", { error: String(error) }); + throw error; + } + } + + /** + * Get total count of admins in the system + */ + static async getAdminCount(): Promise { + const log = logger.setContext("UserService"); + log.debug("Getting user count"); + + try { + const result = await centralDb.select().from(centralSchema.user); + + const count = result.length; + log.debug("User count retrieved", { count }); + return count; + } catch (error) { + log.error("Failed to get user count", { error: String(error) }); + throw error; + } + } +} diff --git a/src/lib/server/utils/errors.ts b/src/lib/server/utils/errors.ts index 14988ec..03cb50f 100644 --- a/src/lib/server/utils/errors.ts +++ b/src/lib/server/utils/errors.ts @@ -1,3 +1,9 @@ +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } +} export class ValidationError extends Error { constructor(message: string) { super(message); diff --git a/src/lib/server/utils/passphrase.ts b/src/lib/server/utils/passphrase.ts new file mode 100644 index 0000000..a03bda2 --- /dev/null +++ b/src/lib/server/utils/passphrase.ts @@ -0,0 +1,61 @@ +import { randomBytes } from "node:crypto"; +import { hash, verify } from "argon2"; + +/** + * Generate a random recovery passphrase + * Uses a combination of words and numbers for better memorability + */ +export function generateRecoveryPassphrase(): string { + // Generate 16 random bytes and convert to base64 + const randomData = randomBytes(16); + const base64 = randomData.toString("base64"); + + // Convert to a more user-friendly format + // Remove padding and special characters, add hyphens for readability + const cleaned = base64.replace(/[+/=]/g, "").toLowerCase(); + + // Split into groups of 4 characters with hyphens + const groups = []; + for (let i = 0; i < cleaned.length; i += 4) { + groups.push(cleaned.slice(i, i + 4)); + } + + return groups.join("-"); +} + +/** + * Hash a passphrase using Argon2 + */ +export async function hashPassphrase(passphrase: string): Promise { + return hash(passphrase, { + type: 2, // Argon2id + memoryCost: 2 ** 16, // 64 MB + timeCost: 3, + parallelism: 1 + }); +} + +/** + * Verify a passphrase against its hash + */ +export async function verifyPassphrase(hash: string, passphrase: string): Promise { + try { + return await verify(hash, passphrase); + } catch { + return false; + } +} + +/** + * Validate passphrase strength (minimum requirements) + */ +export function validatePassphraseStrength(passphrase: string): boolean { + // Minimum 12 characters for security + if (passphrase.length < 12) { + return false; + } + + // No additional complexity requirements for passphrases + // (they can be long sentences) + return true; +} diff --git a/src/routes/api/admin/exists/+server.test.ts b/src/routes/api/admin/exists/+server.test.ts deleted file mode 100644 index 1bde646..0000000 --- a/src/routes/api/admin/exists/+server.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GET } from "./+server"; - -// Mock dependencies -vi.mock("$lib/server/services/admin-account-service", () => ({ - AdminAccountService: { - adminExists: vi.fn(), - getAdminCount: vi.fn() - } -})); - -vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn() - })) - } -})); - -// Import after mocking -import { AdminAccountService } from "$lib/server/services/admin-account-service"; - -describe("GET /api/admin/exists", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should return admin exists status when admin exists", async () => { - vi.mocked(AdminAccountService.adminExists).mockResolvedValue(true); - vi.mocked(AdminAccountService.getAdminCount).mockResolvedValue(2); - - const response = await GET(); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - exists: true, - count: 2 - }); - expect(AdminAccountService.adminExists).toHaveBeenCalledOnce(); - expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce(); - }); - - it("should return admin does not exist when no admin found", async () => { - vi.mocked(AdminAccountService.adminExists).mockResolvedValue(false); - vi.mocked(AdminAccountService.getAdminCount).mockResolvedValue(0); - - const response = await GET(); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - exists: false, - count: 0 - }); - expect(AdminAccountService.adminExists).toHaveBeenCalledOnce(); - expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce(); - }); - - it("should handle service errors", async () => { - vi.mocked(AdminAccountService.adminExists).mockRejectedValue(new Error("Database error")); - - const response = await GET(); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(AdminAccountService.adminExists).toHaveBeenCalledOnce(); - expect(AdminAccountService.getAdminCount).not.toHaveBeenCalled(); - }); - - it("should handle getAdminCount error", async () => { - vi.mocked(AdminAccountService.adminExists).mockResolvedValue(true); - vi.mocked(AdminAccountService.getAdminCount).mockRejectedValue(new Error("Count error")); - - const response = await GET(); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(AdminAccountService.adminExists).toHaveBeenCalledOnce(); - expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce(); - }); -}); \ No newline at end of file diff --git a/src/routes/api/admin/register/+server.ts b/src/routes/api/admin/register/+server.ts deleted file mode 100644 index d981472..0000000 --- a/src/routes/api/admin/register/+server.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { AdminAccountService } from "$lib/server/services/admin-account-service"; -import { ValidationError } from "$lib/server/utils/errors"; -import type { RequestHandler } from "./$types"; -import { registerOpenAPIRoute } from "$lib/server/openapi"; -import logger from "$lib/logger"; - -// Register OpenAPI documentation -registerOpenAPIRoute("/admin/register", "POST", { - summary: "Register a new admin account", - description: "Creates a new admin account that requires email confirmation", - tags: ["Admin"], - requestBody: { - description: "Admin registration data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { type: "string", description: "Admin's full name", example: "Admin Name" }, - email: { - type: "string", - format: "email", - description: "Admin's email address", - example: "admin@example.com" - }, - passkey: { - type: "object", - description: "WebAuthn passkey data", - properties: { - id: { type: "string", description: "Credential ID from WebAuthn" }, - publicKey: { type: "string", description: "Base64 encoded public key" }, - counter: { type: "integer", description: "Signature counter", default: 0 }, - deviceName: { type: "string", description: "Device name for identification", example: "MacBook Pro" } - }, - required: ["id", "publicKey"] - } - }, - required: ["name", "email", "passkey"] - } - } - } - }, - responses: { - "201": { - description: "Admin account created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - adminId: { type: "string", description: "Generated admin ID" }, - email: { type: "string", description: "Admin's email address" } - }, - required: ["message", "adminId", "email"] - }, - example: { - message: - "Admin account created successfully. Please check your email for confirmation.", - adminId: "01234567-89ab-cdef-0123-456789abcdef", - email: "admin@example.com" - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid admin data" } - } - } - }, - "409": { - description: "Admin with this email already exists", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "An admin with this email already exists" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } -}); - -export const POST: RequestHandler = async ({ request }) => { - const log = logger.setContext("API"); - - try { - const body = await request.json(); - - log.debug("Creating admin account with passkey", { - email: body.email, - passkeyId: body.passkey?.id, - deviceName: body.passkey?.deviceName - }); - - // Create admin account - const admin = await AdminAccountService.createAdmin({ - name: body.name, - email: body.email - }); - - // Add the passkey to the admin account - await AdminAccountService.addPasskey(admin.id, { - id: body.passkey.id, - publicKey: body.passkey.publicKey, - counter: body.passkey.counter || 0, - deviceName: body.passkey.deviceName || "Unknown Device" - }); - - log.debug("Admin account and passkey created successfully", { - adminId: admin.id, - email: admin.email, - passkeyId: body.passkey.id - }); - - return json( - { - message: "Admin account created successfully. Please check your email for confirmation.", - adminId: admin.id, - email: admin.email - }, - { status: 201 } - ); - } catch (error) { - log.error("Admin registration error:", JSON.stringify(error || "?")); - - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } - - // Handle unique constraint violation (email already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "An admin with this email already exists" }, { status: 409 }); - } - - return json({ error: "Internal server error" }, { status: 500 }); - } -}; diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts new file mode 100644 index 0000000..0064946 --- /dev/null +++ b/src/routes/api/auth/challenge/+server.ts @@ -0,0 +1,150 @@ +import { json } from "@sveltejs/kit"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; +import { UserService } from "$lib/server/services/user-service"; +import { NotFoundError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthChallengeAPI"); + +registerOpenAPIRoute("/auth/challenge", "POST", { + summary: "Generate WebAuthn authentication challenge", + description: "Generate a challenge for WebAuthn authentication and return registered passkeys", + tags: ["Authentication"], + requestBody: { + description: "User email to generate challenge for", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User's email address", + example: "admin@example.com" + } + }, + required: ["email"] + } + } + } + }, + responses: { + "200": { + description: "Challenge generated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challenge: { + type: "string", + description: "Base64url encoded challenge" + }, + allowCredentials: { + type: "array", + description: "List of registered passkeys for this user", + items: { + type: "object", + properties: { + id: { type: "string", description: "Credential ID" }, + type: { type: "string", enum: ["public-key"] }, + transports: { + type: "array", + items: { type: "string" }, + description: "Supported transports" + } + } + } + }, + timeout: { + type: "number", + description: "Timeout in milliseconds" + } + }, + required: ["challenge", "allowCredentials"] + } + } + } + }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "User not found" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ request, cookies }) => { + try { + const body = await request.json(); + + logger.debug("Generating WebAuthn challenge", { email: body.email }); + + // Get user by email + let user; + try { + user = await UserService.getUserByEmail(body.email); + } catch (error) { + if (error instanceof NotFoundError) { + return json({ error: "User not found" }, { status: 404 }); + } + throw error; + } + + // Generate challenge + const challenge = WebAuthnService.generateChallenge(); + + // Store challenge in session cookie (in production, use proper session storage) + cookies.set("webauthn-challenge", challenge, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 5 // 5 minutes + }); + + // Get user's registered passkeys + const passkeys = await WebAuthnService.getUserPasskeys(user.id); + + // Format passkeys for WebAuthn API + const allowCredentials = passkeys.map((passkey) => ({ + id: passkey.id, + type: "public-key" as const, + transports: ["usb", "nfc", "ble", "internal"] // All possible transports + })); + + logger.debug("WebAuthn challenge generated", { + userId: user.id, + email: user.email, + passkeyCount: passkeys.length, + challenge: challenge.substring(0, 8) + "..." + }); + + return json({ + challenge, + allowCredentials, + timeout: 60000, // 60 seconds + rpId: "localhost", // TODO: Configure for production + userVerification: "preferred" + }); + } catch (error) { + logger.error("Challenge generation error", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; \ No newline at end of file diff --git a/src/routes/api/admin/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts similarity index 63% rename from src/routes/api/admin/confirm/+server.ts rename to src/routes/api/auth/confirm/+server.ts index 793f5d4..27eae45 100644 --- a/src/routes/api/admin/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -1,15 +1,15 @@ import { json } from "@sveltejs/kit"; -import { AdminAccountService } from "$lib/server/services/admin-account-service"; +import { UserService } from "$lib/server/services/user-service"; import { NotFoundError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; // Register OpenAPI documentation -registerOpenAPIRoute("/admin/confirm", "POST", { - summary: "Confirm admin account", - description: "Confirms an admin account using the email confirmation token", - tags: ["Admin"], +registerOpenAPIRoute("/auth/confirm", "POST", { + summary: "Confirm user account", + description: "Confirms a user account using the email confirmation token", + tags: ["Authentication"], requestBody: { description: "Confirmation token", content: { @@ -30,7 +30,7 @@ registerOpenAPIRoute("/admin/confirm", "POST", { }, responses: { "200": { - description: "Admin account confirmed successfully", + description: "User account confirmed successfully", content: { "application/json": { schema: { @@ -41,7 +41,7 @@ registerOpenAPIRoute("/admin/confirm", "POST", { required: ["message"] }, example: { - message: "Admin account confirmed successfully. You can now log in." + message: "User account confirmed successfully. You can now log in." } } } @@ -71,17 +71,23 @@ export const POST: RequestHandler = async ({ request }) => { try { const body = await request.json(); - await AdminAccountService.confirm(body.token); + const confirmationResult = await UserService.confirm(body.token); - return json( - { - message: "Admin account confirmed successfully. You can now log in." - }, - { status: 200 } - ); + const response: Record = { + message: "User account confirmed successfully. You can now log in." + }; + + // Include recovery passphrase if it exists (for WebAuthn-only users) + if (confirmationResult.recoveryPassphrase) { + response.recoveryPassphrase = confirmationResult.recoveryPassphrase; + response.recoveryMessage = + "Please save this recovery passphrase in a secure location. It will not be shown again."; + } + + return json(response, { status: 200 }); } catch (error) { const log = logger.setContext("API"); - log.error("Admin confirmation error:", JSON.stringify(error || "?")); + log.error("User confirmation error:", JSON.stringify(error || "?")); if (error instanceof NotFoundError) { return json({ error: "Invalid or expired confirmation token" }, { status: 404 }); diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..6f55f99 --- /dev/null +++ b/src/routes/api/auth/login/+server.ts @@ -0,0 +1,256 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; +import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; +import { verifyPassphrase } from "$lib/server/utils/passphrase"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthLoginAPI"); + +registerOpenAPIRoute("/auth/login", "POST", { + summary: "Login with WebAuthn passkey or passphrase", + description: + "Authenticate user with WebAuthn passkey or passphrase and create session. For WebAuthn, first call /auth/challenge to get a challenge.", + tags: ["Authentication"], + requestBody: { + description: "Authentication data (either WebAuthn credential or passphrase)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User's email address", + example: "admin@example.com" + }, + passphrase: { + type: "string", + description: "User's passphrase (alternative to WebAuthn)", + example: "my-secure-passphrase-123" + }, + credential: { + type: "object", + description: "WebAuthn credential data (alternative to passphrase)", + properties: { + id: { type: "string", description: "Credential ID" }, + response: { + type: "object", + description: "WebAuthn response data", + properties: { + authenticatorData: { + type: "string", + description: "Base64 encoded authenticator data" + }, + signature: { type: "string", description: "Base64 encoded signature" }, + userHandle: { type: "string", description: "User handle" }, + clientDataJSON: { + type: "string", + description: "Base64 encoded client data JSON" + } + }, + required: ["authenticatorData", "signature", "clientDataJSON"] + } + }, + required: ["id", "response"] + } + }, + required: ["email"] + } + } + } + }, + responses: { + "200": { + description: "Login successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + user: { + type: "object", + properties: { + id: { type: "string", description: "User ID" }, + email: { type: "string", description: "User email" }, + name: { type: "string", description: "User name" }, + role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, + tenantId: { type: "string", description: "Tenant ID (if applicable)" } + }, + required: ["id", "email", "name", "role"] + }, + accessToken: { type: "string", description: "JWT access token" }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time" + } + }, + required: ["message", "user", "accessToken", "expiresAt"] + } + } + } + }, + "400": { + description: "Invalid credentials or request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "401": { + description: "Authentication failed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + try { + const body = await request.json(); + const ipAddress = getClientAddress(); + const userAgent = request.headers.get("user-agent"); + + logger.debug("Login attempt", { + email: body.email, + ipAddress, + authMethod: body.passphrase ? "passphrase" : "webauthn" + }); + + // Validate that either passphrase or credential is provided + if (!body.passphrase && !body.credential) { + return json( + { error: "Either passphrase or WebAuthn credential must be provided" }, + { status: 400 } + ); + } + + // Get user by email + let user; + try { + user = await UserService.getUserByEmail(body.email); + } catch (error) { + if (error instanceof NotFoundError) { + return json({ error: "Invalid email or authentication method" }, { status: 401 }); + } + throw error; + } + + // Handle passphrase authentication + if (body.passphrase) { + if (!user.passphraseHash) { + return json( + { error: "Passphrase authentication not enabled for this user" }, + { status: 401 } + ); + } + + const isPassphraseValid = await verifyPassphrase(user.passphraseHash, body.passphrase); + if (!isPassphraseValid) { + return json({ error: "Invalid passphrase" }, { status: 401 }); + } + + logger.debug("Passphrase authentication successful", { userId: user.id }); + } + + // Handle WebAuthn authentication + if (body.credential) { + // Get the challenge from the session + const challengeFromSession = cookies.get("webauthn-challenge"); + if (!challengeFromSession) { + return json( + { error: "No WebAuthn challenge found. Please request a new challenge." }, + { status: 400 } + ); + } + + // Clear the challenge cookie after use + cookies.delete("webauthn-challenge", { path: "/" }); + + const verificationResult = await WebAuthnService.verifyAuthentication( + body.credential, + challengeFromSession + ); + + if (!verificationResult.verified) { + return json({ error: "Invalid WebAuthn credential" }, { status: 401 }); + } + + // Verify that the credential belongs to the user + if (verificationResult.userId !== user.id) { + return json({ error: "WebAuthn credential does not belong to this user" }, { status: 401 }); + } + + // Update the counter to prevent replay attacks + if (verificationResult.newCounter && verificationResult.passkeyId) { + await WebAuthnService.updatePasskeyCounter( + verificationResult.passkeyId, + verificationResult.newCounter + ); + } + + logger.debug("WebAuthn authentication successful", { userId: user.id }); + } + + const sessionData = await SessionService.createSession( + user.id, + ipAddress, + userAgent || undefined + ); + + // Set HTTP-only cookie for session + cookies.set("session", sessionData.sessionToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 7 // 7 days + }); + + logger.info("Login successful", { + userId: sessionData.user.id, + email: sessionData.user.email, + role: sessionData.user.role, + authMethod: body.passphrase ? "passphrase" : "webauthn" + }); + + return json({ + message: "Login successful", + user: { + id: sessionData.user.id, + email: sessionData.user.email, + name: sessionData.user.name, + role: sessionData.user.role, + tenantId: sessionData.user.tenantId + }, + accessToken: sessionData.accessToken, + expiresAt: sessionData.expiresAt.toISOString() + }); + } catch (error) { + logger.error("Login error:", { error: String(error) }); + + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } + + return json({ error: "Authentication failed" }, { status: 401 }); + } +}; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..58cd134 --- /dev/null +++ b/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,71 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthLogoutAPI"); + +registerOpenAPIRoute("/auth/logout", "POST", { + summary: "Logout user session", + description: "Invalidate current user session and clear session cookie", + tags: ["Authentication"], + responses: { + "200": { + description: "Logout successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" } + }, + required: ["message"] + } + } + } + }, + "400": { + description: "No active session found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ cookies }) => { + try { + const sessionToken = cookies.get("session"); + + if (!sessionToken) { + return json({ error: "No active session found" }, { status: 400 }); + } + + await SessionService.logout(sessionToken); + + cookies.delete("session", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" + }); + + logger.info("Logout successful"); + + return json({ message: "Logout successful" }); + } catch (error) { + logger.error("Logout error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts new file mode 100644 index 0000000..c78dbd3 --- /dev/null +++ b/src/routes/api/auth/passkeys/+server.ts @@ -0,0 +1,157 @@ +import { json } from "@sveltejs/kit"; +import { UserService } from "$lib/server/services/user-service"; +import { NotFoundError, ValidationError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; + +// Register OpenAPI documentation +registerOpenAPIRoute("/auth/passkeys", "POST", { + summary: "Add additional WebAuthn passkey to user account", + description: "Allows authenticated users to add additional WebAuthn keys to their accounts", + tags: ["Authentication"], + requestBody: { + description: "WebAuthn passkey data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + userId: { + type: "string", + format: "uuid", + description: "User ID to add the passkey to", + example: "01234567-89ab-cdef-0123-456789abcdef" + }, + passkey: { + type: "object", + description: "WebAuthn passkey data", + properties: { + id: { type: "string", description: "Credential ID from WebAuthn" }, + publicKey: { type: "string", description: "Base64 encoded public key" }, + counter: { type: "integer", description: "Signature counter", default: 0 }, + deviceName: { + type: "string", + description: "Device name for identification", + example: "iPhone 15" + } + }, + required: ["id", "publicKey"] + } + }, + required: ["userId", "passkey"] + } + } + } + }, + responses: { + "200": { + description: "WebAuthn passkey added successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + passkeyId: { type: "string", description: "ID of the added passkey" } + }, + required: ["message", "passkeyId"] + }, + example: { + message: "WebAuthn passkey added successfully", + passkeyId: "credential_id_123" + } + } + } + }, + "400": { + description: "Invalid input data or user account not confirmed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "User account must be confirmed before adding additional passkeys" } + } + } + }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "User not found" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ request }) => { + const log = logger.setContext("API"); + + try { + const body = await request.json(); + + // Validate required fields + if (!body.userId || !body.passkey) { + return json({ error: "userId and passkey are required" }, { status: 400 }); + } + + if (!body.passkey.id || !body.passkey.publicKey) { + return json({ error: "Passkey must include id and publicKey" }, { status: 400 }); + } + + log.debug("Adding additional passkey to user", { + userId: body.userId, + passkeyId: body.passkey.id, + deviceName: body.passkey.deviceName + }); + + // Add the passkey using the UserService + await UserService.addAdditionalPasskey(body.userId, { + id: body.passkey.id, + userId: body.userId, + publicKey: body.passkey.publicKey, + counter: body.passkey.counter || 0, + deviceName: body.passkey.deviceName || "Unknown Device" + }); + + log.debug("Additional passkey added successfully", { + userId: body.userId, + passkeyId: body.passkey.id + }); + + return json( + { + message: "WebAuthn passkey added successfully", + passkeyId: body.passkey.id + }, + { status: 200 } + ); + } catch (error) { + log.error("Add passkey error:", JSON.stringify(error || "?")); + + if (error instanceof NotFoundError) { + return json({ error: "User not found" }, { status: 404 }); + } + + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } + + // Handle unique constraint violation (passkey already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "This passkey is already registered" }, { status: 409 }); + } + + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 0000000..0f2ce35 --- /dev/null +++ b/src/routes/api/auth/refresh/+server.ts @@ -0,0 +1,102 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthRefreshAPI"); + +registerOpenAPIRoute("/auth/refresh", "POST", { + summary: "Refresh access token", + description: "Generate new access and refresh tokens using existing refresh token", + tags: ["Authentication"], + requestBody: { + description: "Refresh token data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + refreshToken: { + type: "string", + description: "Current refresh token" + } + }, + required: ["refreshToken"] + } + } + } + }, + responses: { + "200": { + description: "Token refresh successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + accessToken: { type: "string", description: "New JWT access token" }, + refreshToken: { type: "string", description: "New refresh token" }, + expiresAt: { type: "string", format: "date-time", description: "New expiration time" } + }, + required: ["message", "accessToken", "refreshToken", "expiresAt"] + } + } + } + }, + "400": { + description: "Invalid refresh token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "401": { + description: "Refresh token expired or invalid", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { refreshToken } = body; + + if (!refreshToken) { + return json({ error: "Refresh token is required" }, { status: 400 }); + } + + const result = await SessionService.refreshSession(refreshToken); + + if (!result) { + return json({ error: "Invalid or expired refresh token" }, { status: 401 }); + } + + logger.info("Token refresh successful"); + + return json({ + message: "Token refresh successful", + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresAt: result.expiresAt.toISOString() + }); + } catch (error) { + logger.error("Token refresh error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts new file mode 100644 index 0000000..0543d7f --- /dev/null +++ b/src/routes/api/auth/register/+server.ts @@ -0,0 +1,187 @@ +import { json } from "@sveltejs/kit"; +import { UserService } from "$lib/server/services/user-service"; +import { ValidationError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; + +// Register OpenAPI documentation +registerOpenAPIRoute("/auth/register", "POST", { + summary: "Register a new user account", + description: "Creates a new user account that requires email confirmation", + tags: ["Authentication"], + requestBody: { + description: "User registration data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", description: "User's full name", example: "John Doe" }, + email: { + type: "string", + format: "email", + description: "User's email address", + example: "user@example.com" + }, + role: { + type: "string", + enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"], + description: "User's role in the system", + example: "STAFF" + }, + tenantId: { + type: "string", + format: "uuid", + description: "Tenant ID for TENANT_ADMIN and STAFF roles", + example: "01234567-89ab-cdef-0123-456789abcdef" + }, + passphrase: { + type: "string", + minLength: 12, + description: "Optional passphrase for password authentication (min 12 chars)", + example: "my-secure-passphrase-123" + }, + passkey: { + type: "object", + description: "WebAuthn passkey data", + properties: { + id: { type: "string", description: "Credential ID from WebAuthn" }, + publicKey: { type: "string", description: "Base64 encoded public key" }, + counter: { type: "integer", description: "Signature counter", default: 0 }, + deviceName: { + type: "string", + description: "Device name for identification", + example: "MacBook Pro" + } + }, + required: ["id", "publicKey"] + } + }, + required: ["name", "email", "role"] + } + } + } + }, + responses: { + "201": { + description: "User account created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + userId: { type: "string", description: "Generated user ID" }, + email: { type: "string", description: "User's email address" } + }, + required: ["message", "userId", "email"] + }, + example: { + message: "User account created successfully. Please check your email for confirmation.", + userId: "01234567-89ab-cdef-0123-456789abcdef", + email: "user@example.com" + } + } + } + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid user data" } + } + } + }, + "409": { + description: "User with this email already exists", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "A user with this email already exists" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" } + } + } + } + } +}); + +export const POST: RequestHandler = async ({ request }) => { + const log = logger.setContext("API"); + + try { + const body = await request.json(); + + // Validate that either passphrase or passkey is provided + if (!body.passphrase && !body.passkey) { + return json({ error: "Either passphrase or passkey must be provided" }, { status: 400 }); + } + + log.debug("Creating user account", { + email: body.email, + role: body.role, + tenantId: body.tenantId, + hasPassphrase: !!body.passphrase, + passkeyId: body.passkey?.id, + deviceName: body.passkey?.deviceName + }); + + // Create user account + const user = await UserService.createUser({ + name: body.name, + email: body.email, + role: body.role, + tenantId: body.tenantId, + passphrase: body.passphrase + }); + + // Add the passkey to the user account if provided + if (body.passkey) { + await UserService.addPasskey(user.id, { + id: body.passkey.id, + publicKey: body.passkey.publicKey, + counter: body.passkey.counter || 0, + deviceName: body.passkey.deviceName || "Unknown Device" + }); + } + + log.debug("User account and passkey created successfully", { + userId: user.id, + email: user.email, + role: user.role, + tenantId: user.tenantId, + passkeyId: body.passkey.id + }); + + return json( + { + message: "User account created successfully. Please check your email for confirmation.", + userId: user.id, + email: user.email + }, + { status: 201 } + ); + } catch (error) { + log.error("User registration error:", JSON.stringify(error || "?")); + + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } + + // Handle unique constraint violation (email already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "A user with this email already exists" }, { status: 409 }); + } + + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/admin/resend-confirmation/+server.ts b/src/routes/api/auth/resend-confirmation/+server.ts similarity index 74% rename from src/routes/api/admin/resend-confirmation/+server.ts rename to src/routes/api/auth/resend-confirmation/+server.ts index 6407d12..4b4b405 100644 --- a/src/routes/api/admin/resend-confirmation/+server.ts +++ b/src/routes/api/auth/resend-confirmation/+server.ts @@ -1,17 +1,17 @@ import { json } from "@sveltejs/kit"; -import { AdminAccountService } from "$lib/server/services/admin-account-service"; +import { UserService } from "$lib/server/services/user-service"; import { NotFoundError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; // Register OpenAPI documentation -registerOpenAPIRoute("/admin/resend-confirmation", "POST", { +registerOpenAPIRoute("/auth/resend-confirmation", "POST", { summary: "Resend confirmation email", - description: "Resends the confirmation email to an admin account", - tags: ["Admin"], + description: "Resends the confirmation email to a user account", + tags: ["Authentication"], requestBody: { - description: "Admin email address", + description: "User email address", content: { "application/json": { schema: { @@ -20,8 +20,8 @@ registerOpenAPIRoute("/admin/resend-confirmation", "POST", { email: { type: "string", format: "email", - description: "Admin's email address", - example: "admin@example.com" + description: "User's email address", + example: "user@example.com" } }, required: ["email"] @@ -48,11 +48,11 @@ registerOpenAPIRoute("/admin/resend-confirmation", "POST", { } }, "404": { - description: "No admin found with this email address", + description: "No user found with this email address", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, - example: { error: "No admin found with this email address" } + example: { error: "No user found with this email address" } } } }, @@ -73,7 +73,7 @@ export const POST: RequestHandler = async ({ request }) => { const body = await request.json(); // Resend confirmation email - await AdminAccountService.resendConfirmationEmail(body.email); + await UserService.resendConfirmationEmail(body.email); return json( { @@ -86,7 +86,7 @@ export const POST: RequestHandler = async ({ request }) => { log.error("Resend confirmation error:", JSON.stringify(error || "?")); if (error instanceof NotFoundError) { - return json({ error: "No admin found with this email address" }, { status: 404 }); + return json({ error: "No user found with this email address" }, { status: 404 }); } return json({ error: "Internal server error" }, { status: 500 }); diff --git a/src/routes/api/auth/session/+server.ts b/src/routes/api/auth/session/+server.ts new file mode 100644 index 0000000..4e395e0 --- /dev/null +++ b/src/routes/api/auth/session/+server.ts @@ -0,0 +1,107 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthSessionAPI"); + +registerOpenAPIRoute("/auth/session", "GET", { + summary: "Get current session status", + description: "Retrieve current user session information and authentication status", + tags: ["Authentication"], + responses: { + "200": { + description: "Session information retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + authenticated: { type: "boolean", description: "Whether user is authenticated" }, + user: { + type: "object", + properties: { + id: { type: "string", description: "User ID" }, + email: { type: "string", description: "User email" }, + name: { type: "string", description: "User name" }, + role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, + tenantId: { type: "string", description: "Tenant ID (if applicable)" } + }, + required: ["id", "email", "name", "role"] + }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time" + } + }, + required: ["authenticated"] + } + } + } + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { + type: "object", + properties: { + authenticated: { type: "boolean", example: false }, + message: { type: "string", description: "Authentication status message" } + }, + required: ["authenticated"] + } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const GET: RequestHandler = async ({ cookies }) => { + try { + const sessionToken = cookies.get("session"); + + if (!sessionToken) { + return json({ authenticated: false, message: "No session found" }, { status: 401 }); + } + + const sessionData = await SessionService.validateSession(sessionToken); + + if (!sessionData) { + cookies.delete("session", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" + }); + return json({ authenticated: false, message: "Invalid or expired session" }, { status: 401 }); + } + + logger.debug("Session validated", { userId: sessionData.user.id }); + + return json({ + authenticated: true, + user: { + id: sessionData.user.id, + email: sessionData.user.email, + name: sessionData.user.name, + role: sessionData.user.role, + tenantId: sessionData.user.tenantId + }, + expiresAt: sessionData.expiresAt.toISOString() + }); + } catch (error) { + logger.error("Session validation error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/sessions/+server.ts b/src/routes/api/auth/sessions/+server.ts new file mode 100644 index 0000000..d46c394 --- /dev/null +++ b/src/routes/api/auth/sessions/+server.ts @@ -0,0 +1,180 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthSessionsAPI"); + +registerOpenAPIRoute("/auth/sessions", "GET", { + summary: "Get all active sessions", + description: "Retrieve all active sessions for the current user", + tags: ["Authentication"], + responses: { + "200": { + description: "Active sessions retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + sessions: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", description: "Session ID" }, + ipAddress: { type: "string", description: "IP address of session" }, + userAgent: { type: "string", description: "User agent string" }, + createdAt: { + type: "string", + format: "date-time", + description: "Session creation time" + }, + lastUsedAt: { + type: "string", + format: "date-time", + description: "Last activity time" + }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time" + }, + current: { type: "boolean", description: "Whether this is the current session" } + }, + required: ["id", "createdAt", "lastUsedAt", "expiresAt", "current"] + } + } + }, + required: ["sessions"] + } + } + } + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +registerOpenAPIRoute("/auth/sessions", "DELETE", { + summary: "Logout all sessions", + description: "Invalidate all active sessions for the current user", + tags: ["Authentication"], + responses: { + "200": { + description: "All sessions logged out successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" } + }, + required: ["message"] + } + } + } + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const GET: RequestHandler = async ({ cookies }) => { + try { + const sessionToken = cookies.get("session"); + + if (!sessionToken) { + return json({ error: "Not authenticated" }, { status: 401 }); + } + + const sessionData = await SessionService.validateSession(sessionToken); + + if (!sessionData) { + return json({ error: "Invalid session" }, { status: 401 }); + } + + const sessions = await SessionService.getActiveSessions(sessionData.user.id); + + const formattedSessions = sessions.map((session) => ({ + id: session.id, + ipAddress: session.ipAddress, + userAgent: session.userAgent, + createdAt: session.createdAt?.toISOString(), + lastUsedAt: session.lastUsedAt?.toISOString(), + expiresAt: session.expiresAt.toISOString(), + current: session.sessionToken === sessionToken + })); + + logger.debug("Active sessions retrieved", { + userId: sessionData.user.id, + sessionCount: sessions.length + }); + + return json({ sessions: formattedSessions }); + } catch (error) { + logger.error("Get sessions error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ cookies }) => { + try { + const sessionToken = cookies.get("session"); + + if (!sessionToken) { + return json({ error: "Not authenticated" }, { status: 401 }); + } + + const sessionData = await SessionService.validateSession(sessionToken); + + if (!sessionData) { + return json({ error: "Invalid session" }, { status: 401 }); + } + + await SessionService.logoutAllSessions(sessionData.user.id); + + cookies.delete("session", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" + }); + + logger.info("All sessions logged out", { userId: sessionData.user.id }); + + return json({ message: "All sessions logged out successfully" }); + } catch (error) { + logger.error("Logout all sessions error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/routes/api/auth/sessions/[sessionId]/+server.ts b/src/routes/api/auth/sessions/[sessionId]/+server.ts new file mode 100644 index 0000000..efc2e8e --- /dev/null +++ b/src/routes/api/auth/sessions/[sessionId]/+server.ts @@ -0,0 +1,119 @@ +import { json } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import type { RequestHandler } from "./$types"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("AuthSessionRevokeAPI"); + +registerOpenAPIRoute("/auth/sessions/{sessionId}", "DELETE", { + summary: "Revoke specific session", + description: "Invalidate a specific session by ID", + tags: ["Authentication"], + parameters: [ + { + name: "sessionId", + in: "path", + description: "Session ID to revoke", + required: true, + schema: { type: "string" } + } + ], + responses: { + "200": { + description: "Session revoked successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" } + }, + required: ["message"] + } + } + } + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "403": { + description: "Cannot revoke session of another user", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "404": { + description: "Session not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" } + } + } + } + } +}); + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + try { + const sessionToken = cookies.get("session"); + const sessionIdToRevoke = params.sessionId; + + if (!sessionToken) { + return json({ error: "Not authenticated" }, { status: 401 }); + } + + const sessionData = await SessionService.validateSession(sessionToken); + + if (!sessionData) { + return json({ error: "Invalid session" }, { status: 401 }); + } + + // Get all user sessions to verify ownership + const userSessions = await SessionService.getActiveSessions(sessionData.user.id); + const targetSession = userSessions.find((session) => session.id === sessionIdToRevoke); + + if (!targetSession) { + return json({ error: "Session not found" }, { status: 404 }); + } + + // Check if trying to revoke current session + if (targetSession.sessionToken === sessionToken) { + // Clear current session cookie + cookies.delete("session", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict" + }); + } + + await SessionService.revokeSession(sessionIdToRevoke); + + logger.info("Session revoked", { + userId: sessionData.user.id, + revokedSessionId: sessionIdToRevoke, + currentSession: targetSession.sessionToken === sessionToken + }); + + return json({ message: "Session revoked successfully" }); + } catch (error) { + logger.error("Session revoke error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } +}; diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/authHandle.ts new file mode 100644 index 0000000..242be57 --- /dev/null +++ b/src/server-hooks/authHandle.ts @@ -0,0 +1,108 @@ +import type { Handle } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import { verifyAccessToken } from "$lib/server/auth/jwt-utils"; +import { UniversalLogger } from "$lib/logger"; +import type { JWTPayload } from "jose"; +import { AuthorizationService } from "$lib/server/auth/authorization-service"; + +const logger = new UniversalLogger().setContext("AuthHandle"); + +const SESSION_COOKIE_NAME = "session"; +const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin"]; +const PUBLIC_PATHS = [ + "/api/auth", + "/api/health", + "/api/docs", + "/api/admin/init", + "/api/admin/confirm", + "/api/admin/exists" +]; +const GLOBAL_ADMIN_PATHS = ["/api/admin"]; +const ADMIN_PATHS = ["/api/tenant-admin"]; + +export const authHandle: Handle = async ({ event, resolve }) => { + const { url, request } = event; + const path = url.pathname; + + const isProtectedPath = PROTECTED_PATHS.some((protectedPath) => path.startsWith(protectedPath)); + const isPublicPath = PUBLIC_PATHS.some((publicPath) => path.startsWith(publicPath)); + const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); + const isAdminPath = ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); + + if (!isProtectedPath || isPublicPath) { + return resolve(event); + } + + let sessionToken: string | null = null; + let accessToken: string | null = null; + + const sessionCookie = event.cookies.get(SESSION_COOKIE_NAME); + if (sessionCookie) { + sessionToken = sessionCookie; + } + + const authHeader = request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + + if (!sessionToken && !accessToken) { + logger.warn(`Authentication required for ${path}`); + return new Response(JSON.stringify({ error: "Authentication required" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + + let user: JWTPayload | null = null; + + if (accessToken) { + user = await verifyAccessToken(accessToken); + if (!user) { + logger.warn(`Invalid access token for ${path}`); + return new Response(JSON.stringify({ error: "Invalid access token" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + } else if (sessionToken) { + const sessionData = await SessionService.validateSession(sessionToken); + if (!sessionData) { + logger.warn(`Invalid session for ${path}`); + return new Response(JSON.stringify({ error: "Invalid session" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + user = await verifyAccessToken(sessionData.accessToken); + } + + if (!user) { + logger.warn(`Authentication failed for ${path}`); + return new Response(JSON.stringify({ error: "Authentication failed" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + + event.locals.user = user; + event.locals.sessionToken = sessionToken || undefined; + if (isGlobalAdminPath && !AuthorizationService.hasRole(user, "GLOBAL_ADMIN")) { + return new Response(JSON.stringify({ error: "Authentication failed" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } else if ( + isAdminPath && + !AuthorizationService.hasAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) + ) { + return new Response(JSON.stringify({ error: "Authentication failed" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + + logger.debug(`User authenticated: ${user.email} for ${path}`); + + return resolve(event); +}; diff --git a/src/server-hooks/startupHandle.ts b/src/server-hooks/startupHandle.ts new file mode 100644 index 0000000..f429040 --- /dev/null +++ b/src/server-hooks/startupHandle.ts @@ -0,0 +1,34 @@ +import type { Handle } from "@sveltejs/kit"; +import { StartupService } from "$lib/server/services/startup-service"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("StartupHandle"); + +// Promise to ensure initialization happens only once +let initializationPromise: Promise | null = null; + +export const startupHandle: Handle = async ({ event, resolve }) => { + // Initialize the application on first request + if (!initializationPromise) { + logger.info("Initializing application on first request"); + initializationPromise = StartupService.initialize(); + } + + // Wait for initialization to complete + try { + await initializationPromise; + } catch (error) { + logger.error("Application initialization failed", { error: String(error) }); + // Return a 503 Service Unavailable response + return new Response("Service temporarily unavailable. Please try again later.", { + status: 503, + headers: { + "Content-Type": "text/plain", + "Retry-After": "30" + } + }); + } + + // Continue with the request + return resolve(event); +}; \ No newline at end of file