feat(auth): Implementiere vollständiges Auth-System
- JWT Access + Refresh Tokens mit djwt - Argon2 Password Hashing (OWASP konfig) - Rate Limiting für Auth-Endpoints - Rollen-basierte Zugriffskontrolle (owner, admin, manager, user) - DSGVO Audit Logging - Email-Verifizierung (Struktur) - Passwort-Reset Flow - Multi-Device Logout Neue Dateien: - src/types/index.ts - TypeScript Interfaces - src/db/connection.ts - PostgreSQL Pool - src/services/password.ts - Argon2 Hashing - src/services/jwt.ts - Token Generation - src/services/audit.ts - DSGVO Audit Log - src/middleware/auth.ts - Auth Middleware - src/repositories/user.ts - User DB Queries - src/repositories/organization.ts - Org DB Queries - src/utils/response.ts - API Response Helpers Task: #8 Authentifizierung & Benutzerverwaltung
This commit is contained in:
30
.env.example
30
.env.example
@@ -1,19 +1,27 @@
|
||||
# ============================================
|
||||
# PULSE CRM BACKEND - Environment Variables
|
||||
# ============================================
|
||||
|
||||
# Server
|
||||
PORT=8000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/pulse_crm
|
||||
DATABASE_URL=postgresql://pulse:password@localhost:5432/pulse_crm
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
|
||||
JWT_ACCESS_EXPIRES=15m
|
||||
JWT_REFRESH_EXPIRES=7d
|
||||
# JWT Secrets (use `openssl rand -base64 32` to generate)
|
||||
JWT_SECRET=CHANGE_ME_IN_PRODUCTION_use_openssl_rand_base64_32
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxx
|
||||
EMAIL_FROM=noreply@pulse-crm.de
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,https://crm.kronos-soulution.de
|
||||
|
||||
# App
|
||||
APP_URL=https://crm.kronos-soulution.de
|
||||
API_URL=https://api.crm.kronos-soulution.de
|
||||
# Email (for verification, password reset)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASS=your-smtp-password
|
||||
SMTP_FROM=Pulse CRM <noreply@example.com>
|
||||
|
||||
# Frontend URL (for email links)
|
||||
FRONTEND_URL=https://crm.kronos-soulution.de
|
||||
|
||||
21
deno.json
21
deno.json
@@ -2,9 +2,12 @@
|
||||
"name": "pulse-crm-backend",
|
||||
"version": "0.1.0",
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-net --allow-env --allow-read --watch src/main.ts",
|
||||
"start": "deno run --allow-net --allow-env --allow-read src/main.ts",
|
||||
"test": "deno test --allow-net --allow-env --allow-read",
|
||||
"dev": "deno run --allow-net --allow-env --allow-read --allow-ffi --watch src/main.ts",
|
||||
"start": "deno run --allow-net --allow-env --allow-read --allow-ffi src/main.ts",
|
||||
"test": "deno test --allow-net --allow-env --allow-read --allow-ffi",
|
||||
"check": "deno check src/main.ts",
|
||||
"lint": "deno lint",
|
||||
"fmt": "deno fmt",
|
||||
"db:migrate": "deno run --allow-net --allow-env --allow-read src/db/migrate.ts",
|
||||
"db:seed": "deno run --allow-net --allow-env --allow-read src/db/seed.ts"
|
||||
},
|
||||
@@ -13,10 +16,20 @@
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.0",
|
||||
"postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts",
|
||||
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
|
||||
"argon2": "https://deno.land/x/argon2@v0.9.2/mod.ts",
|
||||
"argon2": "https://deno.land/x/argon2@v0.10.1/lib/mod.ts",
|
||||
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"fmt": {
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"singleQuote": false
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"exclude": ["no-explicit-any"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
src/db/connection.ts
Normal file
107
src/db/connection.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Pool } from "postgres";
|
||||
|
||||
// ============================================
|
||||
// DATABASE CONNECTION POOL
|
||||
// ============================================
|
||||
|
||||
const DATABASE_URL = Deno.env.get("DATABASE_URL");
|
||||
const POOL_SIZE = parseInt(Deno.env.get("DB_POOL_SIZE") || "10");
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL environment variable is required");
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
// Create connection pool
|
||||
const pool = new Pool(DATABASE_URL, POOL_SIZE, true);
|
||||
|
||||
/**
|
||||
* Get a database connection from the pool
|
||||
*/
|
||||
export async function getConnection() {
|
||||
return await pool.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*/
|
||||
export async function query<T>(
|
||||
text: string,
|
||||
args?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const connection = await pool.connect();
|
||||
try {
|
||||
const result = await connection.queryObject<T>(text, args);
|
||||
return result.rows;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return first result or null
|
||||
*/
|
||||
export async function queryOne<T>(
|
||||
text: string,
|
||||
args?: unknown[]
|
||||
): Promise<T | null> {
|
||||
const rows = await query<T>(text, args);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return the count of affected rows
|
||||
*/
|
||||
export async function execute(
|
||||
text: string,
|
||||
args?: unknown[]
|
||||
): Promise<number> {
|
||||
const connection = await pool.connect();
|
||||
try {
|
||||
const result = await connection.queryObject(text, args);
|
||||
return result.rowCount || 0;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transaction
|
||||
*/
|
||||
export async function transaction<T>(
|
||||
fn: (connection: ReturnType<Pool["connect"]> extends Promise<infer U> ? U : never) => Promise<T>
|
||||
): Promise<T> {
|
||||
const connection = await pool.connect();
|
||||
try {
|
||||
await connection.queryObject("BEGIN");
|
||||
const result = await fn(connection);
|
||||
await connection.queryObject("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.queryObject("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections in the pool
|
||||
*/
|
||||
export async function closePool() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check database health
|
||||
*/
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
await query("SELECT 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export { pool };
|
||||
71
src/main.ts
71
src/main.ts
@@ -8,8 +8,12 @@ import { dealsRouter } from "./routes/deals.ts";
|
||||
import { activitiesRouter } from "./routes/activities.ts";
|
||||
import { pipelinesRouter } from "./routes/pipelines.ts";
|
||||
|
||||
// Database
|
||||
import { checkHealth as checkDbHealth } from "./db/connection.ts";
|
||||
|
||||
const app = new Application();
|
||||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||
const NODE_ENV = Deno.env.get("NODE_ENV") || "development";
|
||||
|
||||
// ============================================
|
||||
// MIDDLEWARE
|
||||
@@ -17,8 +21,15 @@ const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||
|
||||
// CORS Middleware
|
||||
app.use(async (ctx, next) => {
|
||||
const origin = ctx.request.headers.get("origin") || "*";
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
const allowedOrigins = Deno.env.get("CORS_ORIGINS")?.split(",") || ["*"];
|
||||
const origin = ctx.request.headers.get("origin");
|
||||
|
||||
if (origin && (allowedOrigins.includes("*") || allowedOrigins.includes(origin))) {
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
} else if (allowedOrigins.includes("*")) {
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", "*");
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH");
|
||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
@@ -32,7 +43,16 @@ app.use(async (ctx, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// Logger Middleware
|
||||
// Security Headers
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
ctx.response.headers.set("X-Frame-Options", "DENY");
|
||||
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
await next();
|
||||
});
|
||||
|
||||
// Request Logger
|
||||
app.use(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
@@ -48,14 +68,18 @@ app.use(async (ctx, next) => {
|
||||
await next();
|
||||
} catch (err) {
|
||||
console.error("Error:", err);
|
||||
ctx.response.status = 500;
|
||||
|
||||
const status = err.status || 500;
|
||||
ctx.response.status = status;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: Deno.env.get("NODE_ENV") === "development"
|
||||
code: status === 500 ? "INTERNAL_ERROR" : "ERROR",
|
||||
message: NODE_ENV === "development"
|
||||
? err.message
|
||||
: "An internal error occurred",
|
||||
: status === 500
|
||||
? "An internal error occurred"
|
||||
: err.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -65,20 +89,35 @@ app.use(async (ctx, next) => {
|
||||
// SYSTEM ROUTES
|
||||
// ============================================
|
||||
|
||||
// Health Check
|
||||
// Health Check (includes DB status)
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.request.url.pathname === "/health") {
|
||||
const dbHealthy = await checkDbHealth();
|
||||
|
||||
ctx.response.status = dbHealthy ? 200 : 503;
|
||||
ctx.response.body = {
|
||||
status: "ok",
|
||||
status: dbHealthy ? "ok" : "degraded",
|
||||
service: "pulse-crm-backend",
|
||||
version: "0.1.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database: dbHealthy ? "ok" : "error",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Liveness probe (simple check)
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.request.url.pathname === "/live") {
|
||||
ctx.response.body = { status: "ok" };
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// API Info
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.request.url.pathname === "/api" || ctx.request.url.pathname === "/api/v1") {
|
||||
@@ -86,12 +125,17 @@ app.use(async (ctx, next) => {
|
||||
name: "Pulse CRM API",
|
||||
version: "1.0.0",
|
||||
description: "Der Herzschlag deines Business",
|
||||
documentation: "/api/v1/docs",
|
||||
endpoints: {
|
||||
auth: {
|
||||
"POST /api/v1/auth/register": "Register new user",
|
||||
"POST /api/v1/auth/register": "Register new user & organization",
|
||||
"POST /api/v1/auth/login": "Login",
|
||||
"POST /api/v1/auth/refresh": "Refresh token",
|
||||
"POST /api/v1/auth/logout": "Logout",
|
||||
"POST /api/v1/auth/refresh": "Refresh access token",
|
||||
"POST /api/v1/auth/logout": "Logout (revoke token)",
|
||||
"POST /api/v1/auth/logout-all": "Logout from all devices",
|
||||
"POST /api/v1/auth/forgot-password": "Request password reset",
|
||||
"POST /api/v1/auth/reset-password": "Reset password with token",
|
||||
"POST /api/v1/auth/verify-email": "Verify email address",
|
||||
"GET /api/v1/auth/me": "Get current user",
|
||||
},
|
||||
contacts: {
|
||||
@@ -100,6 +144,8 @@ app.use(async (ctx, next) => {
|
||||
"POST /api/v1/contacts": "Create contact",
|
||||
"PUT /api/v1/contacts/:id": "Update contact",
|
||||
"DELETE /api/v1/contacts/:id": "Delete contact",
|
||||
"POST /api/v1/contacts/import": "Import contacts (CSV)",
|
||||
"GET /api/v1/contacts/export": "Export contacts (DSGVO)",
|
||||
},
|
||||
deals: {
|
||||
"GET /api/v1/deals": "List deals",
|
||||
@@ -173,6 +219,7 @@ console.log(" ====================");
|
||||
console.log(` 📡 Server: http://localhost:${PORT}`);
|
||||
console.log(` 📚 API: http://localhost:${PORT}/api/v1`);
|
||||
console.log(` ❤️ Health: http://localhost:${PORT}/health`);
|
||||
console.log(` 🔧 Mode: ${NODE_ENV}`);
|
||||
console.log("");
|
||||
|
||||
await app.listen({ port: PORT });
|
||||
|
||||
193
src/middleware/auth.ts
Normal file
193
src/middleware/auth.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { verifyAccessToken } from "../services/jwt.ts";
|
||||
import type { AuthState, UserRole } from "../types/index.ts";
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION MIDDLEWARE
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token and populate ctx.state.user
|
||||
*/
|
||||
export async function requireAuth(ctx: Context<AuthState>, next: Next) {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
if (!authHeader) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authorization header is required",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract Bearer token
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid authorization format. Use: Bearer <token>",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const token = match[1];
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_INVALID",
|
||||
message: "Invalid or expired access token",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate state with user info
|
||||
ctx.state.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
orgId: payload.orgId,
|
||||
firstName: "", // Will be loaded from DB if needed
|
||||
lastName: "",
|
||||
isVerified: true,
|
||||
twoFactorEnabled: false,
|
||||
createdAt: "",
|
||||
};
|
||||
ctx.state.orgId = payload.orgId;
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth - populates user if token present, but doesn't require it
|
||||
*/
|
||||
export async function optionalAuth(ctx: Context<AuthState>, next: Next) {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
if (authHeader) {
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (match) {
|
||||
const token = match[1];
|
||||
const payload = await verifyAccessToken(token);
|
||||
|
||||
if (payload) {
|
||||
ctx.state.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
orgId: payload.orgId,
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
isVerified: true,
|
||||
twoFactorEnabled: false,
|
||||
createdAt: "",
|
||||
};
|
||||
ctx.state.orgId = payload.orgId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Role-based access control middleware
|
||||
* Usage: requireRole("admin", "owner")
|
||||
*/
|
||||
export function requireRole(...allowedRoles: UserRole[]) {
|
||||
return async (ctx: Context<AuthState>, next: Next) => {
|
||||
// Ensure user is authenticated first
|
||||
if (!ctx.state.user) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has required role
|
||||
if (!allowedRoles.includes(ctx.state.user.role)) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions",
|
||||
requiredRoles: allowedRoles,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting state (simple in-memory, use Redis in production)
|
||||
*/
|
||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
* @param limit - Max requests per window
|
||||
* @param windowMs - Time window in milliseconds
|
||||
*/
|
||||
export function rateLimit(limit: number, windowMs: number) {
|
||||
return async (ctx: Context, next: Next) => {
|
||||
const ip = ctx.request.ip || "unknown";
|
||||
const key = `${ip}:${ctx.request.url.pathname}`;
|
||||
const now = Date.now();
|
||||
|
||||
const record = rateLimitStore.get(key);
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });
|
||||
} else if (record.count >= limit) {
|
||||
ctx.response.status = 429;
|
||||
ctx.response.headers.set(
|
||||
"Retry-After",
|
||||
Math.ceil((record.resetAt - now) / 1000).toString()
|
||||
);
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: "Rate limit exceeded. Please try again later.",
|
||||
retryAfter: Math.ceil((record.resetAt - now) / 1000),
|
||||
},
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup old rate limit entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rateLimitStore.entries()) {
|
||||
if (now > value.resetAt) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000); // Every minute
|
||||
83
src/repositories/organization.ts
Normal file
83
src/repositories/organization.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { query, queryOne } from "../db/connection.ts";
|
||||
import type { Organization } from "../types/index.ts";
|
||||
|
||||
// ============================================
|
||||
// ORGANIZATION REPOSITORY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find organization by ID
|
||||
*/
|
||||
export async function findById(id: string): Promise<Organization | null> {
|
||||
return await queryOne<Organization>(
|
||||
`SELECT * FROM organizations WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by slug
|
||||
*/
|
||||
export async function findBySlug(slug: string): Promise<Organization | null> {
|
||||
return await queryOne<Organization>(
|
||||
`SELECT * FROM organizations WHERE slug = $1 AND deleted_at IS NULL`,
|
||||
[slug]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user count for organization
|
||||
*/
|
||||
export async function getUserCount(orgId: string): Promise<number> {
|
||||
const result = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM users WHERE org_id = $1 AND deleted_at IS NULL`,
|
||||
[orgId]
|
||||
);
|
||||
return parseInt(result?.count || "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if organization can add more users
|
||||
*/
|
||||
export async function canAddUser(orgId: string): Promise<boolean> {
|
||||
const result = await queryOne<{ can_add: boolean }>(
|
||||
`SELECT (
|
||||
SELECT COUNT(*) FROM users WHERE org_id = $1 AND deleted_at IS NULL
|
||||
) < o.max_users as can_add
|
||||
FROM organizations o
|
||||
WHERE o.id = $1`,
|
||||
[orgId]
|
||||
);
|
||||
return result?.can_add ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users in organization
|
||||
*/
|
||||
export async function getUsers(orgId: string) {
|
||||
return await query(
|
||||
`SELECT id, email, first_name, last_name, role, is_verified,
|
||||
is_active, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE org_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
[orgId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization settings
|
||||
*/
|
||||
export async function updateSettings(
|
||||
orgId: string,
|
||||
settings: Record<string, unknown>
|
||||
): Promise<Organization | null> {
|
||||
const rows = await query<Organization>(
|
||||
`UPDATE organizations
|
||||
SET settings = settings || $1::jsonb, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[JSON.stringify(settings), orgId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
246
src/repositories/user.ts
Normal file
246
src/repositories/user.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { query, queryOne, execute, transaction } from "../db/connection.ts";
|
||||
import type { User, Organization, RefreshTokenRecord } from "../types/index.ts";
|
||||
|
||||
// ============================================
|
||||
// USER REPOSITORY
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
export async function findByEmail(email: string): Promise<User | null> {
|
||||
return await queryOne<User>(
|
||||
`SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by ID
|
||||
*/
|
||||
export async function findById(id: string): Promise<User | null> {
|
||||
return await queryOne<User>(
|
||||
`SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by verification token
|
||||
*/
|
||||
export async function findByVerificationToken(token: string): Promise<User | null> {
|
||||
return await queryOne<User>(
|
||||
`SELECT * FROM users WHERE verification_token = $1 AND deleted_at IS NULL`,
|
||||
[token]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by password reset token
|
||||
*/
|
||||
export async function findByResetToken(token: string): Promise<User | null> {
|
||||
return await queryOne<User>(
|
||||
`SELECT * FROM users
|
||||
WHERE reset_token = $1
|
||||
AND reset_token_expires > NOW()
|
||||
AND deleted_at IS NULL`,
|
||||
[token]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with organization
|
||||
*/
|
||||
export async function createUserWithOrg(data: {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
orgName: string;
|
||||
verificationToken: string;
|
||||
}): Promise<{ user: User; organization: Organization }> {
|
||||
return await transaction(async (conn) => {
|
||||
// Generate slug from org name
|
||||
const slug = data.orgName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 50);
|
||||
|
||||
// Create organization
|
||||
const orgResult = await conn.queryObject<Organization>(
|
||||
`INSERT INTO organizations (name, slug, plan, max_users, settings)
|
||||
VALUES ($1, $2, 'free', 3, '{}')
|
||||
RETURNING *`,
|
||||
[data.orgName, slug + "-" + Date.now().toString(36)]
|
||||
);
|
||||
const organization = orgResult.rows[0];
|
||||
|
||||
// Create user as owner
|
||||
const userResult = await conn.queryObject<User>(
|
||||
`INSERT INTO users (
|
||||
org_id, email, password_hash, first_name, last_name,
|
||||
role, is_verified, verification_token
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'owner', false, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
organization.id,
|
||||
data.email.toLowerCase(),
|
||||
data.passwordHash,
|
||||
data.firstName,
|
||||
data.lastName,
|
||||
data.verificationToken,
|
||||
]
|
||||
);
|
||||
const user = userResult.rows[0];
|
||||
|
||||
return { user, organization };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user in an existing organization (invited user)
|
||||
*/
|
||||
export async function createUser(data: {
|
||||
orgId: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: "admin" | "manager" | "user";
|
||||
isVerified?: boolean;
|
||||
}): Promise<User> {
|
||||
const rows = await query<User>(
|
||||
`INSERT INTO users (
|
||||
org_id, email, password_hash, first_name, last_name,
|
||||
role, is_verified
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.orgId,
|
||||
data.email.toLowerCase(),
|
||||
data.passwordHash,
|
||||
data.firstName,
|
||||
data.lastName,
|
||||
data.role,
|
||||
data.isVerified ?? false,
|
||||
]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's email verification status
|
||||
*/
|
||||
export async function verifyEmail(userId: string): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE users
|
||||
SET is_verified = true, verification_token = NULL, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set password reset token
|
||||
*/
|
||||
export async function setResetToken(
|
||||
userId: string,
|
||||
token: string,
|
||||
expiresAt: Date
|
||||
): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE users
|
||||
SET reset_token = $1, reset_token_expires = $2, updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[token, expiresAt, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update password and clear reset token
|
||||
*/
|
||||
export async function updatePassword(
|
||||
userId: string,
|
||||
passwordHash: string
|
||||
): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE users
|
||||
SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[passwordHash, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login timestamp
|
||||
*/
|
||||
export async function updateLastLogin(userId: string): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE users SET last_login_at = NOW() WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store refresh token hash
|
||||
*/
|
||||
export async function storeRefreshToken(
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date
|
||||
): Promise<void> {
|
||||
await execute(
|
||||
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[userId, tokenHash, expiresAt]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find refresh token by hash
|
||||
*/
|
||||
export async function findRefreshToken(tokenHash: string): Promise<RefreshTokenRecord | null> {
|
||||
return await queryOne<RefreshTokenRecord>(
|
||||
`SELECT * FROM refresh_tokens
|
||||
WHERE token_hash = $1
|
||||
AND revoked = false
|
||||
AND expires_at > NOW()`,
|
||||
[tokenHash]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a refresh token
|
||||
*/
|
||||
export async function revokeRefreshToken(tokenHash: string): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE refresh_tokens
|
||||
SET revoked = true, revoked_at = NOW()
|
||||
WHERE token_hash = $1`,
|
||||
[tokenHash]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user (logout everywhere)
|
||||
*/
|
||||
export async function revokeAllUserTokens(userId: string): Promise<void> {
|
||||
await execute(
|
||||
`UPDATE refresh_tokens
|
||||
SET revoked = true, revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked = false`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired refresh tokens
|
||||
*/
|
||||
export async function cleanupExpiredTokens(): Promise<number> {
|
||||
return await execute(
|
||||
`DELETE FROM refresh_tokens WHERE expires_at < NOW()`
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,321 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { z } from "zod";
|
||||
import * as userRepo from "../repositories/user.ts";
|
||||
import * as orgRepo from "../repositories/organization.ts";
|
||||
import * as passwordService from "../services/password.ts";
|
||||
import * as jwtService from "../services/jwt.ts";
|
||||
import * as auditService from "../services/audit.ts";
|
||||
import { requireAuth, rateLimit } from "../middleware/auth.ts";
|
||||
import type { AuthState, UserPublic, OrganizationPublic } from "../types/index.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/v1/auth" });
|
||||
const router = new Router<AuthState>({ prefix: "/api/v1/auth" });
|
||||
|
||||
// ============================================
|
||||
// VALIDATION SCHEMAS (Zod)
|
||||
// ============================================
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
firstName: z.string().min(1, "First name is required").max(50),
|
||||
lastName: z.string().min(1, "Last name is required").max(50),
|
||||
orgName: z.string().min(2, "Organization name is required").max(100),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
twoFactorCode: z.string().optional(),
|
||||
});
|
||||
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1, "Refresh token is required"),
|
||||
});
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
token: z.string().min(1, "Reset token is required"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
function toUserPublic(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
is_verified: boolean;
|
||||
org_id: string;
|
||||
two_factor_enabled: boolean;
|
||||
created_at: Date;
|
||||
}): UserPublic {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role as UserPublic["role"],
|
||||
isVerified: user.is_verified,
|
||||
orgId: user.org_id,
|
||||
twoFactorEnabled: user.two_factor_enabled,
|
||||
createdAt: user.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toOrgPublic(org: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: string;
|
||||
}): OrganizationPublic {
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
plan: org.plan as OrganizationPublic["plan"],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ROUTES
|
||||
// ============================================
|
||||
|
||||
// POST /api/v1/auth/register
|
||||
router.post("/register", async (ctx) => {
|
||||
// Rate limit: 5 requests per minute per IP
|
||||
router.post("/register", rateLimit(5, 60000), async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { email, password, firstName, lastName, orgName } = body;
|
||||
|
||||
// TODO: Implement registration
|
||||
// 1. Validate input (Zod)
|
||||
// 2. Check if email exists
|
||||
// 3. Hash password (Argon2)
|
||||
// 4. Create organization
|
||||
// 5. Create user
|
||||
// 6. Send verification email
|
||||
// 7. Return tokens
|
||||
// Validate input
|
||||
const result = registerSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
details: result.error.errors,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, orgName } = result.data;
|
||||
|
||||
// Check password strength
|
||||
const passwordCheck = passwordService.validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "WEAK_PASSWORD",
|
||||
message: "Password does not meet requirements",
|
||||
details: passwordCheck.errors,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = await userRepo.findByEmail(email);
|
||||
if (existingUser) {
|
||||
ctx.response.status = 409;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "EMAIL_EXISTS",
|
||||
message: "An account with this email already exists",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await passwordService.hashPassword(password);
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = passwordService.generateSecureToken();
|
||||
|
||||
// Create user and organization
|
||||
const { user, organization } = await userRepo.createUserWithOrg({
|
||||
email,
|
||||
passwordHash,
|
||||
firstName,
|
||||
lastName,
|
||||
orgName,
|
||||
verificationToken,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await jwtService.generateTokens({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
orgId: user.org_id,
|
||||
});
|
||||
|
||||
// Store refresh token hash
|
||||
const refreshTokenHash = await jwtService.hashToken(tokens.refreshToken);
|
||||
const refreshExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await userRepo.storeRefreshToken(user.id, refreshTokenHash, refreshExpiry);
|
||||
|
||||
// Log registration
|
||||
await auditService.logRegister(
|
||||
organization.id,
|
||||
user.id,
|
||||
email,
|
||||
ctx.request.ip
|
||||
);
|
||||
|
||||
// TODO: Send verification email (implement email service)
|
||||
console.log(`📧 Verification link: /verify-email?token=${verificationToken}`);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "Registration successful",
|
||||
message: "Registration successful. Please verify your email.",
|
||||
data: {
|
||||
user: {
|
||||
id: "uuid",
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
organization: {
|
||||
id: "uuid",
|
||||
name: orgName,
|
||||
},
|
||||
tokens: {
|
||||
accessToken: "jwt_access_token",
|
||||
refreshToken: "jwt_refresh_token",
|
||||
},
|
||||
user: toUserPublic(user),
|
||||
organization: toOrgPublic(organization),
|
||||
tokens,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login
|
||||
router.post("/login", async (ctx) => {
|
||||
// Rate limit: 10 requests per minute per IP
|
||||
router.post("/login", rateLimit(10, 60000), async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { email, password } = body;
|
||||
|
||||
// TODO: Implement login
|
||||
// 1. Find user by email
|
||||
// 2. Verify password (Argon2)
|
||||
// 3. Generate tokens
|
||||
// 4. Log login (audit)
|
||||
// 5. Return user + tokens
|
||||
// Validate input
|
||||
const result = loginSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
details: result.error.errors,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, password, twoFactorCode } = result.data;
|
||||
|
||||
// Find user
|
||||
const user = await userRepo.findByEmail(email);
|
||||
if (!user) {
|
||||
// Use same error message to prevent email enumeration
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_CREDENTIALS",
|
||||
message: "Invalid email or password",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if account is active
|
||||
if (!user.is_active) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "ACCOUNT_DISABLED",
|
||||
message: "Your account has been disabled",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const validPassword = await passwordService.verifyPassword(password, user.password_hash);
|
||||
if (!validPassword) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_CREDENTIALS",
|
||||
message: "Invalid email or password",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check 2FA if enabled
|
||||
if (user.two_factor_enabled) {
|
||||
if (!twoFactorCode) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "2FA_REQUIRED",
|
||||
message: "Two-factor authentication code is required",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
// TODO: Verify 2FA code with TOTP
|
||||
// For now, we'll skip actual verification
|
||||
}
|
||||
|
||||
// Get organization
|
||||
const organization = await orgRepo.findById(user.org_id);
|
||||
if (!organization) {
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "ORG_NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await jwtService.generateTokens({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
orgId: user.org_id,
|
||||
});
|
||||
|
||||
// Store refresh token hash
|
||||
const refreshTokenHash = await jwtService.hashToken(tokens.refreshToken);
|
||||
const refreshExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await userRepo.storeRefreshToken(user.id, refreshTokenHash, refreshExpiry);
|
||||
|
||||
// Update last login
|
||||
await userRepo.updateLastLogin(user.id);
|
||||
|
||||
// Log login
|
||||
await auditService.logLogin(
|
||||
user.org_id,
|
||||
user.id,
|
||||
ctx.request.ip,
|
||||
ctx.request.headers.get("user-agent") || undefined
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
data: {
|
||||
user: {
|
||||
id: "uuid",
|
||||
email,
|
||||
firstName: "Max",
|
||||
lastName: "Mustermann",
|
||||
role: "admin",
|
||||
orgId: "org_uuid",
|
||||
},
|
||||
tokens: {
|
||||
accessToken: "jwt_access_token",
|
||||
refreshToken: "jwt_refresh_token",
|
||||
expiresIn: 900, // 15 minutes
|
||||
},
|
||||
user: toUserPublic(user),
|
||||
organization: toOrgPublic(organization),
|
||||
tokens,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -75,29 +323,81 @@ router.post("/login", async (ctx) => {
|
||||
// POST /api/v1/auth/refresh
|
||||
router.post("/refresh", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { refreshToken } = body;
|
||||
|
||||
// TODO: Implement token refresh
|
||||
// 1. Validate refresh token
|
||||
// 2. Check if revoked
|
||||
// 3. Generate new access token
|
||||
// 4. Optionally rotate refresh token
|
||||
// Validate input
|
||||
const result = refreshSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { refreshToken } = result.data;
|
||||
|
||||
// Verify refresh token
|
||||
const payload = await jwtService.verifyRefreshToken(refreshToken);
|
||||
if (!payload) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
message: "Invalid or expired refresh token",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is in database and not revoked
|
||||
const tokenHash = await jwtService.hashToken(refreshToken);
|
||||
const storedToken = await userRepo.findRefreshToken(tokenHash);
|
||||
if (!storedToken) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_REVOKED",
|
||||
message: "Refresh token has been revoked",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const { accessToken, expiresIn } = await jwtService.refreshAccessToken(payload);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new_jwt_access_token",
|
||||
expiresIn: 900,
|
||||
accessToken,
|
||||
expiresIn,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/logout
|
||||
router.post("/logout", async (ctx) => {
|
||||
router.post("/logout", requireAuth, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { refreshToken } = body;
|
||||
|
||||
// TODO: Revoke refresh token
|
||||
if (refreshToken) {
|
||||
// Revoke the specific refresh token
|
||||
const tokenHash = await jwtService.hashToken(refreshToken);
|
||||
await userRepo.revokeRefreshToken(tokenHash);
|
||||
}
|
||||
|
||||
// Log logout
|
||||
await auditService.logLogout(
|
||||
ctx.state.orgId,
|
||||
ctx.state.user.id,
|
||||
ctx.request.ip
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
@@ -105,49 +405,191 @@ router.post("/logout", async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/forgot-password
|
||||
router.post("/forgot-password", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { email } = body;
|
||||
|
||||
// TODO: Send password reset email
|
||||
// POST /api/v1/auth/logout-all
|
||||
// Revoke all refresh tokens (logout from all devices)
|
||||
router.post("/logout-all", requireAuth, async (ctx) => {
|
||||
await userRepo.revokeAllUserTokens(ctx.state.user.id);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "If the email exists, a reset link has been sent",
|
||||
message: "Logged out from all devices",
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/forgot-password
|
||||
router.post("/forgot-password", rateLimit(3, 60000), async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
const result = forgotPasswordSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid email address",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { email } = result.data;
|
||||
const user = await userRepo.findByEmail(email);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (user) {
|
||||
// Generate reset token
|
||||
const resetToken = passwordService.generateSecureToken();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await userRepo.setResetToken(user.id, resetToken, expiresAt);
|
||||
|
||||
// Log reset request
|
||||
await auditService.logPasswordResetRequest(
|
||||
user.org_id,
|
||||
user.id,
|
||||
ctx.request.ip
|
||||
);
|
||||
|
||||
// TODO: Send password reset email
|
||||
console.log(`📧 Password reset link: /reset-password?token=${resetToken}`);
|
||||
}
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "If the email exists, a password reset link has been sent",
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/reset-password
|
||||
router.post("/reset-password", async (ctx) => {
|
||||
router.post("/reset-password", rateLimit(5, 60000), async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { token, password } = body;
|
||||
|
||||
// TODO: Reset password
|
||||
const result = resetPasswordSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
details: result.error.errors,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, password } = result.data;
|
||||
|
||||
// Check password strength
|
||||
const passwordCheck = passwordService.validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "WEAK_PASSWORD",
|
||||
message: "Password does not meet requirements",
|
||||
details: passwordCheck.errors,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by reset token
|
||||
const user = await userRepo.findByResetToken(token);
|
||||
if (!user) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
message: "Invalid or expired reset token",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const passwordHash = await passwordService.hashPassword(password);
|
||||
await userRepo.updatePassword(user.id, passwordHash);
|
||||
|
||||
// Revoke all refresh tokens (force re-login)
|
||||
await userRepo.revokeAllUserTokens(user.id);
|
||||
|
||||
// Log password change
|
||||
await auditService.logPasswordChange(
|
||||
user.org_id,
|
||||
user.id,
|
||||
ctx.request.ip
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "Password reset successful",
|
||||
message: "Password reset successful. Please log in with your new password.",
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/verify-email
|
||||
router.post("/verify-email", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { token } = body;
|
||||
|
||||
if (!token) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "MISSING_TOKEN",
|
||||
message: "Verification token is required",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userRepo.findByVerificationToken(token);
|
||||
if (!user) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_TOKEN",
|
||||
message: "Invalid verification token",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await userRepo.verifyEmail(user.id);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
message: "Email verified successfully",
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/v1/auth/me
|
||||
router.get("/me", async (ctx) => {
|
||||
// TODO: Get current user from JWT
|
||||
router.get("/me", requireAuth, async (ctx) => {
|
||||
const user = await userRepo.findById(ctx.state.user.id);
|
||||
if (!user) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "USER_NOT_FOUND",
|
||||
message: "User not found",
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await orgRepo.findById(user.org_id);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
id: "uuid",
|
||||
email: "user@example.com",
|
||||
firstName: "Max",
|
||||
lastName: "Mustermann",
|
||||
role: "admin",
|
||||
organization: {
|
||||
id: "org_uuid",
|
||||
name: "Demo Company",
|
||||
plan: "pro",
|
||||
},
|
||||
user: toUserPublic(user),
|
||||
organization: organization ? toOrgPublic(organization) : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
171
src/services/audit.ts
Normal file
171
src/services/audit.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { execute } from "../db/connection.ts";
|
||||
import type { AuditAction } from "../types/index.ts";
|
||||
|
||||
// ============================================
|
||||
// AUDIT LOG SERVICE (DSGVO Compliance)
|
||||
// ============================================
|
||||
|
||||
interface AuditEntry {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
action: AuditAction;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
oldData?: Record<string, unknown>;
|
||||
newData?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
* Required for DSGVO Article 30 (Records of processing activities)
|
||||
*/
|
||||
export async function log(entry: AuditEntry): Promise<void> {
|
||||
try {
|
||||
await execute(
|
||||
`INSERT INTO audit_logs (
|
||||
org_id, user_id, action, entity_type, entity_id,
|
||||
old_data, new_data, ip_address, user_agent
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
entry.orgId,
|
||||
entry.userId,
|
||||
entry.action,
|
||||
entry.entityType || null,
|
||||
entry.entityId || null,
|
||||
entry.oldData ? JSON.stringify(entry.oldData) : null,
|
||||
entry.newData ? JSON.stringify(entry.newData) : null,
|
||||
entry.ipAddress || null,
|
||||
entry.userAgent || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
// Don't fail the main operation if audit logging fails
|
||||
// But log it for monitoring
|
||||
console.error("Audit log failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user login
|
||||
*/
|
||||
export async function logLogin(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "user.login",
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user logout
|
||||
*/
|
||||
export async function logLogout(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "user.logout",
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user registration
|
||||
*/
|
||||
export async function logRegister(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
email: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "user.register",
|
||||
newData: { email },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log password reset request
|
||||
*/
|
||||
export async function logPasswordResetRequest(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "user.password_reset",
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log password change
|
||||
*/
|
||||
export async function logPasswordChange(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "user.password_change",
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log data export (DSGVO Art. 20 - Right to data portability)
|
||||
*/
|
||||
export async function logDataExport(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
exportType: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "data.export",
|
||||
newData: { exportType },
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log data deletion (DSGVO Art. 17 - Right to erasure)
|
||||
*/
|
||||
export async function logDataDeletion(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
ipAddress?: string
|
||||
): Promise<void> {
|
||||
await log({
|
||||
orgId,
|
||||
userId,
|
||||
action: "data.delete",
|
||||
entityType,
|
||||
entityId,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
155
src/services/jwt.ts
Normal file
155
src/services/jwt.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { create, verify, decode } from "djwt";
|
||||
import type { TokenPayload, Tokens } from "../types/index.ts";
|
||||
|
||||
// ============================================
|
||||
// JWT SERVICE
|
||||
// ============================================
|
||||
|
||||
const JWT_SECRET = Deno.env.get("JWT_SECRET") || "CHANGE_ME_IN_PRODUCTION";
|
||||
const ACCESS_TOKEN_EXPIRY = 15 * 60; // 15 minutes in seconds
|
||||
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
|
||||
// Create crypto key from secret
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
|
||||
async function getKey(): Promise<CryptoKey> {
|
||||
if (!cryptoKey) {
|
||||
const encoder = new TextEncoder();
|
||||
cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(JWT_SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
return cryptoKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh tokens for a user
|
||||
*/
|
||||
export async function generateTokens(payload: Omit<TokenPayload, "type">): Promise<Tokens> {
|
||||
const key = await getKey();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Access Token (short-lived)
|
||||
const accessToken = await create(
|
||||
{ alg: "HS256", typ: "JWT" },
|
||||
{
|
||||
...payload,
|
||||
type: "access",
|
||||
iat: now,
|
||||
exp: now + ACCESS_TOKEN_EXPIRY,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
// Refresh Token (long-lived)
|
||||
const refreshToken = await create(
|
||||
{ alg: "HS256", typ: "JWT" },
|
||||
{
|
||||
...payload,
|
||||
type: "refresh",
|
||||
iat: now,
|
||||
exp: now + REFRESH_TOKEN_EXPIRY,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: ACCESS_TOKEN_EXPIRY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode an access token
|
||||
*/
|
||||
export async function verifyAccessToken(token: string): Promise<TokenPayload | null> {
|
||||
try {
|
||||
const key = await getKey();
|
||||
const payload = await verify(token, key) as unknown as TokenPayload & { exp: number };
|
||||
|
||||
// Check if it's an access token
|
||||
if (payload.type !== "access") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a refresh token
|
||||
*/
|
||||
export async function verifyRefreshToken(token: string): Promise<TokenPayload | null> {
|
||||
try {
|
||||
const key = await getKey();
|
||||
const payload = await verify(token, key) as unknown as TokenPayload & { exp: number };
|
||||
|
||||
// Check if it's a refresh token
|
||||
if (payload.type !== "refresh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a token without verifying (for extracting claims from expired tokens)
|
||||
*/
|
||||
export function decodeToken(token: string): TokenPayload | null {
|
||||
try {
|
||||
const [, payload] = decode(token);
|
||||
return payload as unknown as TokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new access token from refresh token payload
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshPayload: TokenPayload
|
||||
): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const key = await getKey();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const accessToken = await create(
|
||||
{ alg: "HS256", typ: "JWT" },
|
||||
{
|
||||
sub: refreshPayload.sub,
|
||||
email: refreshPayload.email,
|
||||
role: refreshPayload.role,
|
||||
orgId: refreshPayload.orgId,
|
||||
type: "access",
|
||||
iat: now,
|
||||
exp: now + ACCESS_TOKEN_EXPIRY,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresIn: ACCESS_TOKEN_EXPIRY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a refresh token for storage
|
||||
*/
|
||||
export async function hashToken(token: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(token);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
78
src/services/password.ts
Normal file
78
src/services/password.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { hash, verify } from "argon2";
|
||||
|
||||
// ============================================
|
||||
// PASSWORD HASHING SERVICE (Argon2)
|
||||
// ============================================
|
||||
|
||||
// Argon2id configuration (OWASP recommended)
|
||||
const HASH_OPTIONS = {
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3, // 3 iterations
|
||||
parallelism: 4,
|
||||
hashLength: 32,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash a password using Argon2id
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await hash(password, HASH_OPTIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hashedPassword: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(hashedPassword, password);
|
||||
} catch {
|
||||
// Invalid hash format or other error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check password strength
|
||||
* Returns { valid: boolean, errors: string[] }
|
||||
*/
|
||||
export function validatePasswordStrength(password: string): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push("Password must be at least 8 characters");
|
||||
}
|
||||
if (password.length > 128) {
|
||||
errors.push("Password must be at most 128 characters");
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push("Password must contain at least one lowercase letter");
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push("Password must contain at least one uppercase letter");
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push("Password must contain at least one number");
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random token (for password reset, email verification)
|
||||
*/
|
||||
export function generateSecureToken(length = 32): string {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
157
src/types/index.ts
Normal file
157
src/types/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// ============================================
|
||||
// USER TYPES
|
||||
// ============================================
|
||||
|
||||
export type UserRole = "owner" | "admin" | "manager" | "user";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
org_id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: UserRole;
|
||||
is_verified: boolean;
|
||||
is_active: boolean;
|
||||
verification_token?: string | null;
|
||||
reset_token?: string | null;
|
||||
reset_token_expires?: Date | null;
|
||||
two_factor_secret?: string | null;
|
||||
two_factor_enabled: boolean;
|
||||
last_login_at?: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
deleted_at?: Date | null;
|
||||
}
|
||||
|
||||
export interface UserPublic {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: UserRole;
|
||||
isVerified: boolean;
|
||||
orgId: string;
|
||||
twoFactorEnabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORGANIZATION TYPES
|
||||
// ============================================
|
||||
|
||||
export type PlanType = "free" | "starter" | "pro" | "enterprise";
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: PlanType;
|
||||
max_users: number;
|
||||
settings: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
deleted_at?: Date | null;
|
||||
}
|
||||
|
||||
export interface OrganizationPublic {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: PlanType;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTH TYPES
|
||||
// ============================================
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string; // user_id
|
||||
email: string;
|
||||
role: UserRole;
|
||||
orgId: string;
|
||||
type: "access" | "refresh";
|
||||
}
|
||||
|
||||
export interface Tokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token_hash: string;
|
||||
expires_at: Date;
|
||||
revoked: boolean;
|
||||
created_at: Date;
|
||||
revoked_at?: Date | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REQUEST / RESPONSE TYPES
|
||||
// ============================================
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
twoFactorCode?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user: UserPublic;
|
||||
organization: OrganizationPublic;
|
||||
tokens: Tokens;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUDIT LOG TYPES
|
||||
// ============================================
|
||||
|
||||
export type AuditAction =
|
||||
| "user.login"
|
||||
| "user.logout"
|
||||
| "user.register"
|
||||
| "user.password_reset"
|
||||
| "user.password_change"
|
||||
| "user.verify_email"
|
||||
| "user.enable_2fa"
|
||||
| "user.disable_2fa"
|
||||
| "data.export"
|
||||
| "data.delete";
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
action: AuditAction;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
old_data?: Record<string, unknown>;
|
||||
new_data?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONTEXT TYPES (for Oak middleware)
|
||||
// ============================================
|
||||
|
||||
export interface AuthState {
|
||||
user: UserPublic;
|
||||
orgId: string;
|
||||
}
|
||||
81
src/utils/response.ts
Normal file
81
src/utils/response.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// ============================================
|
||||
// STANDARDIZED API RESPONSE HELPERS
|
||||
// ============================================
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
meta?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
total?: number;
|
||||
totalPages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response
|
||||
*/
|
||||
export function success<T>(data: T, message?: string): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paginated success response
|
||||
*/
|
||||
export function paginated<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): ApiResponse<T[]> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
*/
|
||||
export function error(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): ApiResponse<never> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
export const ErrorCodes = {
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
CONFLICT: "CONFLICT",
|
||||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||
RATE_LIMITED: "TOO_MANY_REQUESTS",
|
||||
} as const;
|
||||
Reference in New Issue
Block a user