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
|
# Server
|
||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Database (PostgreSQL)
|
# 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 Secrets (use `openssl rand -base64 32` to generate)
|
||||||
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
|
JWT_SECRET=CHANGE_ME_IN_PRODUCTION_use_openssl_rand_base64_32
|
||||||
JWT_ACCESS_EXPIRES=15m
|
|
||||||
JWT_REFRESH_EXPIRES=7d
|
|
||||||
|
|
||||||
# Email (Resend)
|
# CORS
|
||||||
RESEND_API_KEY=re_xxxxxxxxxxxxx
|
CORS_ORIGINS=http://localhost:3000,https://crm.kronos-soulution.de
|
||||||
EMAIL_FROM=noreply@pulse-crm.de
|
|
||||||
|
|
||||||
# App
|
# Email (for verification, password reset)
|
||||||
APP_URL=https://crm.kronos-soulution.de
|
SMTP_HOST=smtp.example.com
|
||||||
API_URL=https://api.crm.kronos-soulution.de
|
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",
|
"name": "pulse-crm-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run --allow-net --allow-env --allow-read --watch src/main.ts",
|
"dev": "deno run --allow-net --allow-env --allow-read --allow-ffi --watch src/main.ts",
|
||||||
"start": "deno run --allow-net --allow-env --allow-read 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",
|
"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: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"
|
"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",
|
"@std/dotenv": "jsr:@std/dotenv@^0.225.0",
|
||||||
"postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts",
|
"postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts",
|
||||||
"zod": "https://deno.land/x/zod@v3.22.4/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"
|
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true
|
"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 };
|
||||||
69
src/main.ts
69
src/main.ts
@@ -8,8 +8,12 @@ import { dealsRouter } from "./routes/deals.ts";
|
|||||||
import { activitiesRouter } from "./routes/activities.ts";
|
import { activitiesRouter } from "./routes/activities.ts";
|
||||||
import { pipelinesRouter } from "./routes/pipelines.ts";
|
import { pipelinesRouter } from "./routes/pipelines.ts";
|
||||||
|
|
||||||
|
// Database
|
||||||
|
import { checkHealth as checkDbHealth } from "./db/connection.ts";
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||||
|
const NODE_ENV = Deno.env.get("NODE_ENV") || "development";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MIDDLEWARE
|
// MIDDLEWARE
|
||||||
@@ -17,8 +21,15 @@ const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
|||||||
|
|
||||||
// CORS Middleware
|
// CORS Middleware
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
const origin = ctx.request.headers.get("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);
|
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-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-Headers", "Content-Type, Authorization");
|
||||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||||
@@ -32,7 +43,16 @@ app.use(async (ctx, next) => {
|
|||||||
await 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) => {
|
app.use(async (ctx, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await next();
|
await next();
|
||||||
@@ -48,14 +68,18 @@ app.use(async (ctx, next) => {
|
|||||||
await next();
|
await next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error:", err);
|
console.error("Error:", err);
|
||||||
ctx.response.status = 500;
|
|
||||||
|
const status = err.status || 500;
|
||||||
|
ctx.response.status = status;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: "INTERNAL_ERROR",
|
code: status === 500 ? "INTERNAL_ERROR" : "ERROR",
|
||||||
message: Deno.env.get("NODE_ENV") === "development"
|
message: NODE_ENV === "development"
|
||||||
? err.message
|
? 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
|
// SYSTEM ROUTES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Health Check
|
// Health Check (includes DB status)
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
if (ctx.request.url.pathname === "/health") {
|
if (ctx.request.url.pathname === "/health") {
|
||||||
|
const dbHealthy = await checkDbHealth();
|
||||||
|
|
||||||
|
ctx.response.status = dbHealthy ? 200 : 503;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
status: "ok",
|
status: dbHealthy ? "ok" : "degraded",
|
||||||
service: "pulse-crm-backend",
|
service: "pulse-crm-backend",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
checks: {
|
||||||
|
database: dbHealthy ? "ok" : "error",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await next();
|
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
|
// API Info
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (ctx, next) => {
|
||||||
if (ctx.request.url.pathname === "/api" || ctx.request.url.pathname === "/api/v1") {
|
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",
|
name: "Pulse CRM API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "Der Herzschlag deines Business",
|
description: "Der Herzschlag deines Business",
|
||||||
|
documentation: "/api/v1/docs",
|
||||||
endpoints: {
|
endpoints: {
|
||||||
auth: {
|
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/login": "Login",
|
||||||
"POST /api/v1/auth/refresh": "Refresh token",
|
"POST /api/v1/auth/refresh": "Refresh access token",
|
||||||
"POST /api/v1/auth/logout": "Logout",
|
"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",
|
"GET /api/v1/auth/me": "Get current user",
|
||||||
},
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
@@ -100,6 +144,8 @@ app.use(async (ctx, next) => {
|
|||||||
"POST /api/v1/contacts": "Create contact",
|
"POST /api/v1/contacts": "Create contact",
|
||||||
"PUT /api/v1/contacts/:id": "Update contact",
|
"PUT /api/v1/contacts/:id": "Update contact",
|
||||||
"DELETE /api/v1/contacts/:id": "Delete 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: {
|
deals: {
|
||||||
"GET /api/v1/deals": "List deals",
|
"GET /api/v1/deals": "List deals",
|
||||||
@@ -173,6 +219,7 @@ console.log(" ====================");
|
|||||||
console.log(` 📡 Server: http://localhost:${PORT}`);
|
console.log(` 📡 Server: http://localhost:${PORT}`);
|
||||||
console.log(` 📚 API: http://localhost:${PORT}/api/v1`);
|
console.log(` 📚 API: http://localhost:${PORT}/api/v1`);
|
||||||
console.log(` ❤️ Health: http://localhost:${PORT}/health`);
|
console.log(` ❤️ Health: http://localhost:${PORT}/health`);
|
||||||
|
console.log(` 🔧 Mode: ${NODE_ENV}`);
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
await app.listen({ port: PORT });
|
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 { 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
|
// 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 body = await ctx.request.body.json();
|
||||||
const { email, password, firstName, lastName, orgName } = body;
|
|
||||||
|
|
||||||
// TODO: Implement registration
|
// Validate input
|
||||||
// 1. Validate input (Zod)
|
const result = registerSchema.safeParse(body);
|
||||||
// 2. Check if email exists
|
if (!result.success) {
|
||||||
// 3. Hash password (Argon2)
|
ctx.response.status = 400;
|
||||||
// 4. Create organization
|
ctx.response.body = {
|
||||||
// 5. Create user
|
success: false,
|
||||||
// 6. Send verification email
|
error: {
|
||||||
// 7. Return tokens
|
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.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Registration successful",
|
message: "Registration successful. Please verify your email.",
|
||||||
data: {
|
data: {
|
||||||
user: {
|
user: toUserPublic(user),
|
||||||
id: "uuid",
|
organization: toOrgPublic(organization),
|
||||||
email,
|
tokens,
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
id: "uuid",
|
|
||||||
name: orgName,
|
|
||||||
},
|
|
||||||
tokens: {
|
|
||||||
accessToken: "jwt_access_token",
|
|
||||||
refreshToken: "jwt_refresh_token",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/v1/auth/login
|
// 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 body = await ctx.request.body.json();
|
||||||
const { email, password } = body;
|
|
||||||
|
|
||||||
// TODO: Implement login
|
// Validate input
|
||||||
// 1. Find user by email
|
const result = loginSchema.safeParse(body);
|
||||||
// 2. Verify password (Argon2)
|
if (!result.success) {
|
||||||
// 3. Generate tokens
|
ctx.response.status = 400;
|
||||||
// 4. Log login (audit)
|
ctx.response.body = {
|
||||||
// 5. Return user + tokens
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Login successful",
|
message: "Login successful",
|
||||||
data: {
|
data: {
|
||||||
user: {
|
user: toUserPublic(user),
|
||||||
id: "uuid",
|
organization: toOrgPublic(organization),
|
||||||
email,
|
tokens,
|
||||||
firstName: "Max",
|
|
||||||
lastName: "Mustermann",
|
|
||||||
role: "admin",
|
|
||||||
orgId: "org_uuid",
|
|
||||||
},
|
|
||||||
tokens: {
|
|
||||||
accessToken: "jwt_access_token",
|
|
||||||
refreshToken: "jwt_refresh_token",
|
|
||||||
expiresIn: 900, // 15 minutes
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,29 +323,81 @@ router.post("/login", async (ctx) => {
|
|||||||
// POST /api/v1/auth/refresh
|
// POST /api/v1/auth/refresh
|
||||||
router.post("/refresh", async (ctx) => {
|
router.post("/refresh", async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { refreshToken } = body;
|
|
||||||
|
|
||||||
// TODO: Implement token refresh
|
// Validate input
|
||||||
// 1. Validate refresh token
|
const result = refreshSchema.safeParse(body);
|
||||||
// 2. Check if revoked
|
if (!result.success) {
|
||||||
// 3. Generate new access token
|
ctx.response.status = 400;
|
||||||
// 4. Optionally rotate refresh token
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
accessToken: "new_jwt_access_token",
|
accessToken,
|
||||||
expiresIn: 900,
|
expiresIn,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/v1/auth/logout
|
// POST /api/v1/auth/logout
|
||||||
router.post("/logout", async (ctx) => {
|
router.post("/logout", requireAuth, async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { refreshToken } = body;
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -105,49 +405,191 @@ router.post("/logout", async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/v1/auth/forgot-password
|
// POST /api/v1/auth/logout-all
|
||||||
router.post("/forgot-password", async (ctx) => {
|
// Revoke all refresh tokens (logout from all devices)
|
||||||
const body = await ctx.request.body.json();
|
router.post("/logout-all", requireAuth, async (ctx) => {
|
||||||
const { email } = body;
|
await userRepo.revokeAllUserTokens(ctx.state.user.id);
|
||||||
|
|
||||||
// TODO: Send password reset email
|
|
||||||
|
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
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
|
// 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 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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
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
|
// GET /api/v1/auth/me
|
||||||
router.get("/me", async (ctx) => {
|
router.get("/me", requireAuth, async (ctx) => {
|
||||||
// TODO: Get current user from JWT
|
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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
id: "uuid",
|
user: toUserPublic(user),
|
||||||
email: "user@example.com",
|
organization: organization ? toOrgPublic(organization) : null,
|
||||||
firstName: "Max",
|
|
||||||
lastName: "Mustermann",
|
|
||||||
role: "admin",
|
|
||||||
organization: {
|
|
||||||
id: "org_uuid",
|
|
||||||
name: "Demo Company",
|
|
||||||
plan: "pro",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
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