Initial commit: Complete NodeJS-native setup
- Migrated from Python pre-commit to NodeJS-native solution - Reorganized documentation structure - Set up Husky + lint-staged for efficient pre-commit hooks - Fixed Dockerfile healthcheck issue - Added comprehensive documentation index
This commit is contained in:
59
services/auth/__tests__/auth.integration.test.ts
Normal file
59
services/auth/__tests__/auth.integration.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { authService } from '../auth.service';
|
||||
import { AccountStatus } from '../auth.constants';
|
||||
import { User } from '../../../types';
|
||||
|
||||
// Helper to clear localStorage and reset the mock DB before each test
|
||||
beforeEach(() => {
|
||||
// Clear all localStorage keys used by dbService and authService
|
||||
Object.keys(localStorage).forEach(key => localStorage.removeItem(key));
|
||||
});
|
||||
|
||||
describe('Authentication Integration Tests', () => {
|
||||
const username = 'testuser';
|
||||
const password = 'Passw0rd!';
|
||||
const email = 'testuser@example.com';
|
||||
|
||||
test('User registration creates a pending account', async () => {
|
||||
const result = await authService.register(email, password, username);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.username).toBe(username);
|
||||
expect(result.user.email).toBe(email);
|
||||
expect(result.user.status).toBe(AccountStatus.PENDING);
|
||||
expect(result.user.emailVerified).toBeFalsy();
|
||||
});
|
||||
|
||||
test('Login fails for unverified (pending) account', async () => {
|
||||
await expect(authService.login({ email, password })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('Email verification activates the account', async () => {
|
||||
// Register a user first to get the verification token
|
||||
const result = await authService.register(email, password, username);
|
||||
const verificationToken = result.verificationToken.token;
|
||||
|
||||
const verifiedUser = await authService.verifyEmail(verificationToken);
|
||||
expect(verifiedUser.status).toBe(AccountStatus.ACTIVE);
|
||||
expect(verifiedUser.emailVerified).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Login succeeds after email verification', async () => {
|
||||
const tokens = await authService.login({ email, password });
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens.accessToken).toBeTruthy();
|
||||
expect(tokens.refreshToken).toBeTruthy();
|
||||
});
|
||||
|
||||
test('OAuth flow registers or logs in a user', async () => {
|
||||
const oauthEmail = 'oauthuser@example.com';
|
||||
const oauthName = 'OAuth User';
|
||||
const result = await authService.loginWithOAuth('google', {
|
||||
email: oauthEmail,
|
||||
username: oauthName,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user.email).toBe(oauthEmail);
|
||||
// OAuth users should be active immediately
|
||||
expect(result.user.status).toBe(AccountStatus.ACTIVE);
|
||||
expect(result.user.emailVerified).toBeTruthy();
|
||||
});
|
||||
});
|
||||
59
services/auth/__tests__/emailVerification.test.ts
Normal file
59
services/auth/__tests__/emailVerification.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { EmailVerificationService } from '../emailVerification.service';
|
||||
import { dbService } from '../../couchdb.factory';
|
||||
|
||||
jest.mock('../../couchdb.factory');
|
||||
jest.mock('../../email');
|
||||
|
||||
describe('EmailVerificationService', () => {
|
||||
let emailVerificationService: EmailVerificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
emailVerificationService = new EmailVerificationService();
|
||||
});
|
||||
|
||||
test('should generate and validate verification token', async () => {
|
||||
const user = {
|
||||
_id: 'user1',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
const verificationToken =
|
||||
await emailVerificationService.generateVerificationToken(user as any);
|
||||
|
||||
expect(verificationToken).toBeDefined();
|
||||
expect(verificationToken.token).toBeDefined();
|
||||
expect(verificationToken.expiresAt).toBeDefined();
|
||||
|
||||
const validatedUser =
|
||||
await emailVerificationService.validateVerificationToken(
|
||||
verificationToken.token
|
||||
);
|
||||
|
||||
expect(validatedUser).toBeDefined();
|
||||
expect(validatedUser!._id).toBe(user._id);
|
||||
});
|
||||
|
||||
test('should not validate expired token', async () => {
|
||||
const user = {
|
||||
_id: 'user2',
|
||||
email: 'test2@example.com',
|
||||
username: 'testuser2',
|
||||
password: 'password2',
|
||||
};
|
||||
|
||||
const verificationToken =
|
||||
await emailVerificationService.generateVerificationToken(user as any);
|
||||
|
||||
// Set expiresAt to past date
|
||||
verificationToken.expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||
|
||||
const validatedUser =
|
||||
await emailVerificationService.validateVerificationToken(
|
||||
verificationToken.token
|
||||
);
|
||||
|
||||
expect(validatedUser).toBeNull();
|
||||
});
|
||||
});
|
||||
25
services/auth/auth.constants.ts
Normal file
25
services/auth/auth.constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Client-side auth constants for demo/development purposes
|
||||
// In production, these would be handled by a secure backend service
|
||||
|
||||
export const JWT_EXPIRES_IN = '1h';
|
||||
export const REFRESH_TOKEN_EXPIRES_IN = '7d';
|
||||
export const EMAIL_VERIFICATION_EXPIRES_IN = '24h';
|
||||
|
||||
// Mock secrets for frontend-only demo (NOT for production use)
|
||||
export const JWT_SECRET = 'demo_jwt_secret_for_frontend_only';
|
||||
export const REFRESH_TOKEN_SECRET = 'demo_refresh_secret_for_frontend_only';
|
||||
export const EMAIL_VERIFICATION_SECRET =
|
||||
'demo_email_verification_secret_for_frontend_only';
|
||||
|
||||
export enum AccountStatus {
|
||||
PENDING = 'PENDING',
|
||||
ACTIVE = 'ACTIVE',
|
||||
SUSPENDED = 'SUSPENDED',
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
jwtSecret: string;
|
||||
jwtExpiresIn: string;
|
||||
refreshTokenExpiresIn: string;
|
||||
emailVerificationExpiresIn: string;
|
||||
}
|
||||
43
services/auth/auth.error.ts
Normal file
43
services/auth/auth.error.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Custom AuthError class that extends Error with HTTP status code
|
||||
* Security: Provides consistent error handling for authentication issues
|
||||
*/
|
||||
export class AuthError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode: number = 401) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to handle AuthError exceptions
|
||||
* Security: Centralized error handling for authentication errors
|
||||
*/
|
||||
export const handleAuthError = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (err instanceof AuthError) {
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.message,
|
||||
statusCode: err.statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle JWT verification errors
|
||||
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid or expired token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
48
services/auth/auth.middleware.ts
Normal file
48
services/auth/auth.middleware.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from './auth.constants';
|
||||
import { AuthError, handleAuthError } from './auth.error';
|
||||
|
||||
// Security: JWT authentication middleware
|
||||
export const authenticate = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
// Security: Get token from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AuthError('No authentication token provided', 401);
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
|
||||
// Security: Verify JWT token
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
// Add user information to request
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
handleAuthError(error, req, res, next);
|
||||
}
|
||||
};
|
||||
|
||||
// Security: Role-based authorization middleware
|
||||
export const authorize = (...allowedRoles: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Security: Check if user exists in request
|
||||
if (!req.user) {
|
||||
throw new AuthError('Authentication required', 401);
|
||||
}
|
||||
|
||||
// In a full implementation, we would check user roles
|
||||
next();
|
||||
} catch (error) {
|
||||
handleAuthError(error, req, res, next);
|
||||
}
|
||||
};
|
||||
};
|
||||
244
services/auth/auth.service.ts
Normal file
244
services/auth/auth.service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { dbService } from '../../services/couchdb.factory';
|
||||
import { AccountStatus } from './auth.constants';
|
||||
import { User } from '../../types';
|
||||
import { AuthenticatedUser } from './auth.types';
|
||||
import { EmailVerificationService } from './emailVerification.service';
|
||||
|
||||
const emailVerificationService = new EmailVerificationService();
|
||||
|
||||
const authService = {
|
||||
async register(email: string, password: string, username?: string) {
|
||||
try {
|
||||
// Create user with password
|
||||
const user = await dbService.createUserWithPassword(
|
||||
email,
|
||||
password,
|
||||
username
|
||||
);
|
||||
|
||||
// Generate and send verification token (in production)
|
||||
const verificationToken =
|
||||
await emailVerificationService.generateVerificationToken(
|
||||
user as AuthenticatedUser
|
||||
);
|
||||
|
||||
return { user, verificationToken };
|
||||
} catch (error) {
|
||||
if (error.message.includes('already exists')) {
|
||||
throw new Error('An account with this email already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async login(input: { email: string; password: string }) {
|
||||
console.log('🔐 Login attempt for:', input.email);
|
||||
|
||||
// Find user by email
|
||||
const user = await dbService.findUserByEmail(input.email);
|
||||
|
||||
if (!user) {
|
||||
console.log('❌ User not found for email:', input.email);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
console.log('👤 User found:', {
|
||||
email: user.email,
|
||||
hasPassword: !!user.password,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
emailVerified: user.emailVerified,
|
||||
});
|
||||
|
||||
// Check if user has a password (email-based account)
|
||||
if (!user.password) {
|
||||
console.log('❌ No password found - OAuth account');
|
||||
throw new Error(
|
||||
'This account was created with OAuth. Please use Google or GitHub to sign in.'
|
||||
);
|
||||
}
|
||||
|
||||
// Simple password verification (in production, use bcrypt)
|
||||
console.log('🔍 Comparing passwords:', {
|
||||
inputPassword: input.password,
|
||||
storedPassword: user.password,
|
||||
match: user.password === input.password,
|
||||
});
|
||||
|
||||
if (user.password !== input.password) {
|
||||
console.log('❌ Password mismatch');
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
console.log('✅ Login successful for:', user.email);
|
||||
|
||||
// Return mock tokens for frontend compatibility
|
||||
return {
|
||||
user,
|
||||
accessToken: 'mock_access_token_' + Date.now(),
|
||||
refreshToken: 'mock_refresh_token_' + Date.now(),
|
||||
};
|
||||
},
|
||||
|
||||
async loginWithOAuth(
|
||||
provider: 'google' | 'github',
|
||||
userData: { email: string; username: string; avatar?: string }
|
||||
) {
|
||||
try {
|
||||
// Try to find existing user by email
|
||||
let user = await dbService.findUserByEmail(userData.email);
|
||||
|
||||
if (!user) {
|
||||
// Create new user from OAuth data
|
||||
user = await dbService.createUserFromOAuth(userData);
|
||||
}
|
||||
|
||||
// Generate access tokens
|
||||
return {
|
||||
user,
|
||||
accessToken: `oauth_${provider}_token_` + Date.now(),
|
||||
refreshToken: `oauth_${provider}_refresh_` + Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`OAuth login failed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async verifyEmail(token: string) {
|
||||
const user =
|
||||
await emailVerificationService.validateVerificationToken(token);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
await emailVerificationService.markEmailVerified(user);
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
) {
|
||||
// Get user by ID
|
||||
const user = await dbService.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Check if user has a password (not OAuth user)
|
||||
if (!user.password) {
|
||||
throw new Error('Cannot change password for OAuth accounts');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (user.password !== currentPassword) {
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('New password must be at least 6 characters long');
|
||||
}
|
||||
|
||||
// Update password
|
||||
const updatedUser = await dbService.changeUserPassword(userId, newPassword);
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
message: 'Password changed successfully',
|
||||
};
|
||||
},
|
||||
|
||||
async requestPasswordReset(email: string) {
|
||||
const user = await dbService.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return {
|
||||
message:
|
||||
'If an account with this email exists, a password reset link has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error('Cannot reset password for OAuth accounts');
|
||||
}
|
||||
|
||||
// Generate reset token (similar to verification token)
|
||||
const resetToken = uuidv4().replace(/-/g, '');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour expiry
|
||||
|
||||
// Store reset token (in production, save to database)
|
||||
const resetTokens = JSON.parse(
|
||||
localStorage.getItem('password_reset_tokens') || '[]'
|
||||
);
|
||||
resetTokens.push({
|
||||
userId: user._id,
|
||||
email: user.email,
|
||||
token: resetToken,
|
||||
expiresAt,
|
||||
});
|
||||
localStorage.setItem('password_reset_tokens', JSON.stringify(resetTokens));
|
||||
|
||||
// Send reset email
|
||||
const emailSent = await emailVerificationService.sendPasswordResetEmail(
|
||||
user.email!,
|
||||
resetToken
|
||||
);
|
||||
|
||||
return {
|
||||
message:
|
||||
'If an account with this email exists, a password reset link has been sent.',
|
||||
emailSent,
|
||||
};
|
||||
},
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
// Get reset tokens
|
||||
const resetTokens = JSON.parse(
|
||||
localStorage.getItem('password_reset_tokens') || '[]'
|
||||
);
|
||||
const resetToken = resetTokens.find((t: any) => t.token === token);
|
||||
|
||||
if (!resetToken) {
|
||||
throw new Error('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > new Date(resetToken.expiresAt)) {
|
||||
throw new Error('Reset token has expired');
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
|
||||
// Update password
|
||||
const updatedUser = await dbService.changeUserPassword(
|
||||
resetToken.userId,
|
||||
newPassword
|
||||
);
|
||||
|
||||
// Remove used token
|
||||
const filteredTokens = resetTokens.filter((t: any) => t.token !== token);
|
||||
localStorage.setItem(
|
||||
'password_reset_tokens',
|
||||
JSON.stringify(filteredTokens)
|
||||
);
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
message: 'Password reset successfully',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export { authService };
|
||||
export default authService;
|
||||
42
services/auth/auth.types.ts
Normal file
42
services/auth/auth.types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { User } from '../../types';
|
||||
import { AccountStatus } from './auth.constants';
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface EmailVerificationToken {
|
||||
userId: string;
|
||||
email: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
userId: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedUser extends User {
|
||||
status: AccountStatus;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
95
services/auth/emailVerification.service.ts
Normal file
95
services/auth/emailVerification.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EmailVerificationToken, AuthenticatedUser } from './auth.types';
|
||||
import { mailgunService } from '../mailgun.service';
|
||||
import { AccountStatus } from './auth.constants';
|
||||
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
|
||||
export class EmailVerificationService {
|
||||
async generateVerificationToken(
|
||||
user: AuthenticatedUser
|
||||
): Promise<EmailVerificationToken> {
|
||||
const token = uuidv4().replace(/-/g, ''); // Generate a random token using UUID
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + TOKEN_EXPIRY_HOURS);
|
||||
|
||||
const verificationToken: EmailVerificationToken = {
|
||||
userId: user._id,
|
||||
email: user.email || '',
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
// Store token in localStorage for demo (in production, save to database)
|
||||
const tokens = JSON.parse(
|
||||
localStorage.getItem('verification_tokens') || '[]'
|
||||
);
|
||||
tokens.push(verificationToken);
|
||||
localStorage.setItem('verification_tokens', JSON.stringify(tokens));
|
||||
|
||||
// Send verification email via Mailgun
|
||||
if (user.email) {
|
||||
const emailSent = await mailgunService.sendVerificationEmail(
|
||||
user.email,
|
||||
token
|
||||
);
|
||||
if (!emailSent) {
|
||||
console.warn('Failed to send verification email');
|
||||
}
|
||||
}
|
||||
|
||||
return verificationToken;
|
||||
}
|
||||
|
||||
async validateVerificationToken(
|
||||
token: string
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
// Get tokens from localStorage
|
||||
const tokens = JSON.parse(
|
||||
localStorage.getItem('verification_tokens') || '[]'
|
||||
);
|
||||
const verificationToken = tokens.find(
|
||||
(t: EmailVerificationToken) => t.token === token
|
||||
);
|
||||
|
||||
if (!verificationToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (new Date() > new Date(verificationToken.expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the user (in production, this would be a proper database lookup)
|
||||
const { dbService } = await import('../couchdb');
|
||||
const user = await dbService.findUserByEmail(verificationToken.email);
|
||||
|
||||
return user as AuthenticatedUser;
|
||||
}
|
||||
|
||||
async markEmailVerified(user: AuthenticatedUser): Promise<void> {
|
||||
// Update user in database
|
||||
const { dbService } = await import('../couchdb');
|
||||
const updatedUser = {
|
||||
...user,
|
||||
emailVerified: true,
|
||||
status: AccountStatus.ACTIVE,
|
||||
};
|
||||
|
||||
await dbService.updateUser(updatedUser);
|
||||
|
||||
// Remove used token
|
||||
const tokens = JSON.parse(
|
||||
localStorage.getItem('verification_tokens') || '[]'
|
||||
);
|
||||
const filteredTokens = tokens.filter(
|
||||
(t: EmailVerificationToken) => t.userId !== user._id
|
||||
);
|
||||
localStorage.setItem('verification_tokens', JSON.stringify(filteredTokens));
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail(email: string, token: string): Promise<boolean> {
|
||||
return mailgunService.sendPasswordResetEmail(email, token);
|
||||
}
|
||||
}
|
||||
17
services/auth/templates/verification.email.ts
Normal file
17
services/auth/templates/verification.email.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { EmailVerificationToken } from '../auth.types';
|
||||
|
||||
export const verificationEmailTemplate = (token: EmailVerificationToken) => {
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:5173';
|
||||
const verificationLink = `${baseUrl}/verify-email?token=${token.token}`;
|
||||
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<h1>Email Verification</h1>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="${verificationLink}">${verificationLink}</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
Reference in New Issue
Block a user