mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
#53 session handling
This commit is contained in:
@@ -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"
|
||||
|
||||
Vendored
+3
-1
@@ -3,7 +3,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
sessionToken?: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: Omit<JWTPayload, "iat" | "exp"> = {
|
||||
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<string> {
|
||||
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<JWTPayload | null> {
|
||||
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<JWTTokens> {
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
generateAccessToken(user, sessionId),
|
||||
generateRefreshToken(user.id, sessionId)
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken
|
||||
};
|
||||
}
|
||||
|
||||
export async function isTokenExpired(token: string): Promise<boolean> {
|
||||
try {
|
||||
await jwtVerify(token, JWT_SECRET);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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<SessionData> {
|
||||
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<SessionData | null> {
|
||||
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<RefreshResult | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SelectUserSession[]> {
|
||||
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<SelectUser | null> {
|
||||
const sessionData = await this.validateSession(sessionToken);
|
||||
return sessionData ? sessionData.user : null;
|
||||
}
|
||||
|
||||
static async revokeSession(sessionId: string): Promise<void> {
|
||||
logger.info(`Revoking session: ${sessionId}`);
|
||||
|
||||
await centralDb.delete(userSession).where(eq(userSession.id, sessionId));
|
||||
|
||||
logger.info(`Session revoked: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
@@ -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<WebAuthnVerificationResult> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<typeof centralSchema.admin>;
|
||||
export type InsertAdminPasskey = InferInsertModel<typeof centralSchema.adminPasskey>;
|
||||
|
||||
const adminCreationSchema = z.object({
|
||||
name: z.string().min(5),
|
||||
email: z.email(),
|
||||
token: z.uuidv7().optional(),
|
||||
tokenValidUntil: z.date().optional()
|
||||
});
|
||||
|
||||
type AdminCreation = z.infer<typeof adminCreationSchema>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Omit<InsertAdmin, "id" | "createdAt">>
|
||||
) {
|
||||
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<InsertAdminPasskey, "adminId" | "createdAt" | "updatedAt">
|
||||
) {
|
||||
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<Omit<InsertAdminPasskey, "id" | "adminId" | "createdAt">>
|
||||
) {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof centralSchema.user>;
|
||||
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
|
||||
|
||||
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<typeof userCreationSchema>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Omit<InsertUser, "id" | "createdAt">>
|
||||
) {
|
||||
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<InsertUserPasskey, "userId" | "createdAt" | "updatedAt">
|
||||
) {
|
||||
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<Omit<InsertUserPasskey, "id" | "userId" | "createdAt">>
|
||||
) {
|
||||
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<boolean> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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 });
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
+11
-11
@@ -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 });
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<void> | 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);
|
||||
};
|
||||
Reference in New Issue
Block a user