feat(phase-1): implement PostgreSQL + Prisma + Authentication system

Core Features:
- Complete Prisma database schema with all entities (users, orgs, projects, checks, etc.)
- Production-grade authentication service with Argon2 password hashing
- JWT-based session management with HttpOnly cookies
- Comprehensive auth middleware with role-based access control
- RESTful auth API endpoints: register, login, logout, me, refresh
- Database seeding with demo data for development
- Rate limiting on auth endpoints (5 attempts/15min)

Technical Implementation:
- Type-safe authentication with Zod validation
- Proper error handling and logging throughout
- Secure password hashing with Argon2id
- JWT tokens with 7-day expiration
- Database transactions for atomic operations
- Comprehensive middleware for optional/required auth
- Role hierarchy system (MEMBER < ADMIN < OWNER)

Database Schema:
- Users with secure password storage
- Organizations with membership management
- Projects for organizing redirect checks
- Complete audit logging system
- API key management for programmatic access
- Bulk job tracking for future phases

Backward Compatibility:
- All existing endpoints preserved and functional
- No breaking changes to legacy API responses
- New auth system runs alongside existing functionality

Ready for Phase 2: Enhanced redirect tracking with database persistence
This commit is contained in:
Andrei
2025-08-18 07:25:45 +00:00
parent db9e3ef650
commit 459eda89fe
11 changed files with 1364 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ import rateLimit from 'express-rate-limit';
import path from 'path';
import { logger } from './lib/logger';
import { trackRedirects } from './services/redirect-legacy.service';
import authRoutes from './routes/auth.routes';
const app = express();
const PORT = process.env.PORT || 3333;
@@ -58,6 +59,13 @@ const apiLimiter = rateLimit({
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// ============================================================================
// NEW V2 API ROUTES
// ============================================================================
// Authentication routes
app.use('/api/v1/auth', authRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({

View File

@@ -0,0 +1,216 @@
/**
* Authentication Middleware for Redirect Intelligence v2
*
* Provides JWT token authentication and user context injection
*/
import { Request, Response, NextFunction } from 'express';
import { AuthService, AuthUser } from '../services/auth.service';
import { logger } from '../lib/logger';
// Extend Request interface to include user
export interface AuthenticatedRequest extends Request {
user?: AuthUser;
}
export class AuthMiddleware {
private authService = new AuthService();
/**
* Extract token from request headers or cookies
*/
private extractToken(req: Request): string | null {
// Try Authorization header first (Bearer token)
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Try cookie as fallback
const cookieToken = req.cookies?.auth_token;
if (cookieToken) {
return cookieToken;
}
return null;
}
/**
* Required authentication middleware
* Returns 401 if user is not authenticated
*/
requireAuth = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const token = this.extractToken(req);
if (!token) {
logger.warn('Authentication required but no token provided');
return res.status(401).json({
success: false,
error: 'Authentication required',
message: 'Please provide a valid authentication token'
});
}
// Verify token
const decoded = this.authService.verifyToken(token);
// Get user details
const user = await this.authService.getUserById(decoded.userId);
if (!user) {
logger.warn(`Token valid but user not found: ${decoded.userId}`);
return res.status(401).json({
success: false,
error: 'User not found',
message: 'The authenticated user no longer exists'
});
}
// Attach user to request
req.user = user;
logger.debug(`User authenticated: ${user.email}`);
next();
} catch (error) {
logger.warn('Authentication failed:', error);
return res.status(401).json({
success: false,
error: 'Invalid token',
message: 'Please provide a valid authentication token'
});
}
};
/**
* Optional authentication middleware
* Attaches user if token is valid, but doesn't require it
*/
optionalAuth = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const token = this.extractToken(req);
if (!token) {
// No token provided, continue without authentication
return next();
}
// Verify token
const decoded = this.authService.verifyToken(token);
// Get user details
const user = await this.authService.getUserById(decoded.userId);
if (user) {
req.user = user;
logger.debug(`Optional auth successful: ${user.email}`);
}
} catch (error) {
// Token invalid, but that's okay for optional auth
logger.debug('Optional auth failed (continuing):', error);
}
next();
};
/**
* Require specific role in organization
*/
requireRole = (orgIdParam: string, requiredRole: string) => {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: 'Authentication required'
});
}
// Get organization ID from request params
const orgId = req.params[orgIdParam] || req.body[orgIdParam];
if (!orgId) {
return res.status(400).json({
success: false,
error: 'Organization ID required'
});
}
// Check if user has the required role
const userRole = await this.authService.getUserRole(req.user.id, orgId);
if (!userRole) {
return res.status(403).json({
success: false,
error: 'Access denied',
message: 'You are not a member of this organization'
});
}
// Define role hierarchy
const roleHierarchy = {
'MEMBER': 1,
'ADMIN': 2,
'OWNER': 3,
};
const userRoleLevel = roleHierarchy[userRole as keyof typeof roleHierarchy] || 0;
const requiredRoleLevel = roleHierarchy[requiredRole as keyof typeof roleHierarchy] || 999;
if (userRoleLevel < requiredRoleLevel) {
return res.status(403).json({
success: false,
error: 'Insufficient permissions',
message: `This action requires ${requiredRole} role or higher`
});
}
next();
};
};
/**
* Check organization access
*/
requireOrgAccess = (orgIdParam: string) => {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: 'Authentication required'
});
}
const orgId = req.params[orgIdParam] || req.body[orgIdParam];
if (!orgId) {
return res.status(400).json({
success: false,
error: 'Organization ID required'
});
}
const hasAccess = await this.authService.hasOrgAccess(req.user.id, orgId);
if (!hasAccess) {
return res.status(403).json({
success: false,
error: 'Access denied',
message: 'You do not have access to this organization'
});
}
next();
};
};
}
// Export singleton instance
export const authMiddleware = new AuthMiddleware();
// Export individual middleware functions for convenience
export const requireAuth = authMiddleware.requireAuth;
export const optionalAuth = authMiddleware.optionalAuth;
export const requireRole = authMiddleware.requireRole;
export const requireOrgAccess = authMiddleware.requireOrgAccess;

View File

@@ -0,0 +1,266 @@
/**
* Authentication Routes for Redirect Intelligence v2
*
* Handles user login, registration, logout, and profile management
*/
import express from 'express';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import { AuthService } from '../services/auth.service';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
import { logger } from '../lib/logger';
const router = express.Router();
const authService = new AuthService();
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: {
success: false,
error: 'Too many authentication attempts',
message: 'Please try again later'
},
standardHeaders: true,
legacyHeaders: false,
});
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // limit each IP to 3 registration attempts per hour
message: {
success: false,
error: 'Too many registration attempts',
message: 'Please try again later'
},
});
// Input validation schemas
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
password: z.string().min(8, 'Password must be at least 8 characters'),
organizationName: z.string().min(2, 'Organization name must be at least 2 characters').optional(),
});
/**
* POST /api/v1/auth/login
* Authenticate user with email and password
*/
router.post('/login', authLimiter, async (req, res) => {
try {
// Validate input
const validatedData = loginSchema.parse(req.body);
// Attempt login
const { user, token } = await authService.login(validatedData);
// Set HttpOnly cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
// Log successful login
logger.info(`Successful login: ${user.email}`);
res.json({
success: true,
status: 200,
data: {
user,
token, // Also return token for API clients
},
message: 'Login successful'
});
} catch (error) {
logger.warn('Login failed:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation error',
message: error.errors[0]?.message || 'Invalid input',
details: error.errors
});
}
res.status(400).json({
success: false,
error: 'Login failed',
message: error instanceof Error ? error.message : 'Invalid credentials'
});
}
});
/**
* POST /api/v1/auth/register
* Register a new user account
*/
router.post('/register', registerLimiter, async (req, res) => {
try {
// Validate input
const validatedData = registerSchema.parse(req.body);
// Create user
const user = await authService.register(validatedData);
// Log successful registration
logger.info(`New user registered: ${user.email}`);
res.status(201).json({
success: true,
status: 201,
data: { user },
message: 'Registration successful'
});
} catch (error) {
logger.warn('Registration failed:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation error',
message: error.errors[0]?.message || 'Invalid input',
details: error.errors
});
}
const statusCode = error instanceof Error && error.message === 'User already exists' ? 409 : 400;
res.status(statusCode).json({
success: false,
error: 'Registration failed',
message: error instanceof Error ? error.message : 'Registration failed'
});
}
});
/**
* POST /api/v1/auth/logout
* Clear authentication tokens
*/
router.post('/logout', (req, res) => {
// Clear cookie
res.clearCookie('auth_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
});
logger.info('User logged out');
res.json({
success: true,
status: 200,
message: 'Logout successful'
});
});
/**
* GET /api/v1/auth/me
* Get current user profile
*/
router.get('/me', requireAuth, (req: AuthenticatedRequest, res) => {
res.json({
success: true,
status: 200,
data: {
user: req.user
}
});
});
/**
* PUT /api/v1/auth/me
* Update current user profile
*/
router.put('/me', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const updateSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
});
const validatedData = updateSchema.parse(req.body);
// Update user profile (simplified - would need more validation in production)
// This is a placeholder for Phase 1
logger.info(`Profile update requested by user: ${req.user!.email}`);
res.json({
success: true,
status: 200,
data: {
user: req.user
},
message: 'Profile update will be implemented in a future phase'
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation error',
message: error.errors[0]?.message || 'Invalid input',
details: error.errors
});
}
res.status(500).json({
success: false,
error: 'Update failed',
message: 'Failed to update profile'
});
}
});
/**
* POST /api/v1/auth/refresh
* Refresh authentication token
*/
router.post('/refresh', requireAuth, (req: AuthenticatedRequest, res) => {
try {
// Generate new token
const token = authService.generateToken(req.user!.id, req.user!.email);
// Set new cookie
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
res.json({
success: true,
status: 200,
data: { token },
message: 'Token refreshed successfully'
});
} catch (error) {
logger.error('Token refresh failed:', error);
res.status(500).json({
success: false,
error: 'Refresh failed',
message: 'Failed to refresh token'
});
}
});
export default router;

View File

@@ -0,0 +1,326 @@
/**
* Authentication Service for Redirect Intelligence v2
*
* Handles user authentication, password hashing, and JWT token management
*/
import argon2 from 'argon2';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
import { prisma } from '../lib/prisma';
import { logger } from '../lib/logger';
// Input validation schemas
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const registerSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
password: z.string().min(8),
organizationName: z.string().min(2).optional(),
});
export interface AuthUser {
id: string;
email: string;
name: string;
memberships: Array<{
orgId: string;
role: string;
organization: {
name: string;
plan: string;
};
}>;
}
export interface AuthResult {
user: AuthUser;
token: string;
}
export class AuthService {
private readonly JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
private readonly JWT_EXPIRES_IN = '7d';
/**
* Hash a password using Argon2
*/
async hashPassword(password: string): Promise<string> {
try {
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 2 ** 16, // 64 MB
timeCost: 3,
parallelism: 1,
});
} catch (error) {
logger.error('Password hashing failed:', error);
throw new Error('Password hashing failed');
}
}
/**
* Verify a password against its hash
*/
async verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch (error) {
logger.error('Password verification failed:', error);
return false;
}
}
/**
* Generate a JWT token for a user
*/
generateToken(userId: string, email: string): string {
return jwt.sign(
{
userId,
email,
iat: Math.floor(Date.now() / 1000),
},
this.JWT_SECRET,
{ expiresIn: this.JWT_EXPIRES_IN }
);
}
/**
* Verify and decode a JWT token
*/
verifyToken(token: string): { userId: string; email: string } {
try {
const decoded = jwt.verify(token, this.JWT_SECRET) as {
userId: string;
email: string;
};
return decoded;
} catch (error) {
logger.error('Token verification failed:', error);
throw new Error('Invalid token');
}
}
/**
* Login user with email and password
*/
async login(data: z.infer<typeof loginSchema>): Promise<AuthResult> {
const { email, password } = loginSchema.parse(data);
logger.info(`Login attempt for email: ${email}`);
// Find user with memberships and organizations
const user = await prisma.user.findUnique({
where: { email },
include: {
memberships: {
include: {
organization: {
select: {
name: true,
plan: true,
}
}
}
}
}
});
if (!user) {
logger.warn(`Login failed: User not found for email ${email}`);
throw new Error('Invalid credentials');
}
// Verify password
const isValidPassword = await this.verifyPassword(user.passwordHash, password);
if (!isValidPassword) {
logger.warn(`Login failed: Invalid password for email ${email}`);
throw new Error('Invalid credentials');
}
// Update last login timestamp
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
});
// Generate token
const token = this.generateToken(user.id, user.email);
// Format user data for response
const authUser: AuthUser = {
id: user.id,
email: user.email,
name: user.name,
memberships: user.memberships.map(m => ({
orgId: m.orgId,
role: m.role,
organization: {
name: m.organization.name,
plan: m.organization.plan,
}
}))
};
logger.info(`Login successful for user: ${user.email}`);
return { user: authUser, token };
}
/**
* Register a new user
*/
async register(data: z.infer<typeof registerSchema>): Promise<AuthUser> {
const { email, name, password, organizationName } = registerSchema.parse(data);
logger.info(`Registration attempt for email: ${email}`);
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
logger.warn(`Registration failed: User already exists for email ${email}`);
throw new Error('User already exists');
}
// Hash password
const passwordHash = await this.hashPassword(password);
// Create user and organization in a transaction
const result = await prisma.$transaction(async (tx) => {
// Create user
const user = await tx.user.create({
data: {
email,
name,
passwordHash,
}
});
// Create organization (or use default)
const organization = await tx.organization.create({
data: {
name: organizationName || `${name}'s Organization`,
plan: 'free',
}
});
// Create membership
await tx.orgMembership.create({
data: {
userId: user.id,
orgId: organization.id,
role: 'OWNER',
}
});
// Create default project
await tx.project.create({
data: {
name: 'Default Project',
orgId: organization.id,
settingsJson: {
description: 'Default project for redirect tracking',
defaultMethod: 'GET',
},
}
});
return { user, organization };
});
logger.info(`Registration successful for user: ${email}`);
return {
id: result.user.id,
email: result.user.email,
name: result.user.name,
memberships: [{
orgId: result.organization.id,
role: 'OWNER',
organization: {
name: result.organization.name,
plan: result.organization.plan,
}
}]
};
}
/**
* Get user by ID with memberships
*/
async getUserById(userId: string): Promise<AuthUser | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
memberships: {
include: {
organization: {
select: {
name: true,
plan: true,
}
}
}
}
}
});
if (!user) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
memberships: user.memberships.map(m => ({
orgId: m.orgId,
role: m.role,
organization: {
name: m.organization.name,
plan: m.organization.plan,
}
}))
};
}
/**
* Check if user has access to organization
*/
async hasOrgAccess(userId: string, orgId: string): Promise<boolean> {
const membership = await prisma.orgMembership.findUnique({
where: {
orgId_userId: {
orgId,
userId,
}
}
});
return !!membership;
}
/**
* Get user's role in organization
*/
async getUserRole(userId: string, orgId: string): Promise<string | null> {
const membership = await prisma.orgMembership.findUnique({
where: {
orgId_userId: {
orgId,
userId,
}
}
});
return membership?.role || null;
}
}

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirect Intelligence v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

86
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,86 @@
/**
* Redirect Intelligence v2 - Frontend Entry Point
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
// Placeholder component for Phase 1
function App() {
return (
<div style={{
padding: '2rem',
fontFamily: 'system-ui, sans-serif',
maxWidth: '800px',
margin: '0 auto'
}}>
<h1>🚀 Redirect Intelligence v2</h1>
<p>
<strong>Phase 1: PostgreSQL + Prisma + Authentication</strong> is in progress.
</p>
<div style={{
background: '#f0f8ff',
padding: '1rem',
borderRadius: '8px',
marginTop: '2rem'
}}>
<h3> What's Working</h3>
<ul>
<li>Docker Compose infrastructure</li>
<li>TypeScript API server</li>
<li>Backward compatible legacy endpoints</li>
<li>Database schema with Prisma</li>
<li>Authentication system (JWT + Argon2)</li>
</ul>
</div>
<div style={{
background: '#fff8dc',
padding: '1rem',
borderRadius: '8px',
marginTop: '1rem'
}}>
<h3>🚧 Coming Next</h3>
<ul>
<li>Chakra UI frontend (Phase 4)</li>
<li>Enhanced redirect analysis (Phase 2-3)</li>
<li>Bulk processing (Phase 6)</li>
<li>Monitoring & alerts (Phase 10)</li>
</ul>
</div>
<div style={{ marginTop: '2rem' }}>
<h3>🔗 API Endpoints</h3>
<p>Test the API directly:</p>
<ul>
<li><a href="/api/docs">/api/docs</a> - API Documentation</li>
<li><a href="/health">/health</a> - Health Check</li>
<li><code>POST /api/v1/auth/register</code> - User Registration</li>
<li><code>POST /api/v1/auth/login</code> - User Login</li>
<li><code>GET /api/v1/auth/me</code> - User Profile</li>
</ul>
</div>
<div style={{
marginTop: '2rem',
padding: '1rem',
background: '#f5f5f5',
borderRadius: '8px'
}}>
<h4>🧪 Test the Legacy Endpoints (100% Compatible)</h4>
<pre style={{ background: '#000', color: '#0f0', padding: '1rem', overflow: 'auto' }}>
{`curl -X POST ${window.location.origin}/api/v1/track \\
-H "Content-Type: application/json" \\
-d '{"url": "github.com", "method": "GET"}'`}
</pre>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

67
apps/worker/src/index.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Background Worker for Redirect Intelligence v2
*
* Handles bulk jobs, monitoring, and other background tasks
*/
import 'dotenv/config';
import { Worker, Queue } from 'bullmq';
import IORedis from 'ioredis';
const redis = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379');
console.log('🔄 Redirect Intelligence v2 Worker starting...');
// Placeholder worker - will be implemented in later phases
const bulkQueue = new Queue('bulk-checks', { connection: redis });
const monitoringQueue = new Queue('monitoring', { connection: redis });
const bulkWorker = new Worker('bulk-checks', async (job) => {
console.log('Processing bulk job:', job.id);
// Bulk processing logic will be implemented in Phase 6
return { status: 'completed', message: 'Bulk job processing not yet implemented' };
}, { connection: redis });
const monitoringWorker = new Worker('monitoring', async (job) => {
console.log('Processing monitoring job:', job.id);
// Monitoring logic will be implemented in Phase 10
return { status: 'completed', message: 'Monitoring not yet implemented' };
}, { connection: redis });
bulkWorker.on('completed', (job) => {
console.log(`✅ Bulk job ${job.id} completed`);
});
bulkWorker.on('failed', (job, err) => {
console.error(`❌ Bulk job ${job?.id} failed:`, err);
});
monitoringWorker.on('completed', (job) => {
console.log(`✅ Monitoring job ${job.id} completed`);
});
monitoringWorker.on('failed', (job, err) => {
console.error(`❌ Monitoring job ${job?.id} failed:`, err);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('🛑 Shutting down worker...');
await bulkWorker.close();
await monitoringWorker.close();
await redis.quit();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('🛑 Shutting down worker...');
await bulkWorker.close();
await monitoringWorker.close();
await redis.quit();
process.exit(0);
});
console.log('🚀 Worker is ready to process jobs');
console.log(`📡 Connected to Redis: ${process.env.REDIS_URL || 'redis://localhost:6379'}`);
export { bulkQueue, monitoringQueue };