navidocs/server/services/auth.service.js
Danny Stocker 58b344aa31 FINAL: P0 blockers fixed + Joe Trader + ignore binaries
Fixed:
- Price: €800K-€1.5M, Sunseeker added
- Agent 1: Joe Trader persona + actual sale ads research
- Ignored meilisearch binary + data/ (too large for GitHub)
- SESSION_DEBUG_BLOCKERS.md created

Ready for Session 1 launch.

🤖 Generated with Claude Code
2025-11-13 01:29:59 +01:00

530 lines
12 KiB
JavaScript

/**
* Authentication Service
*
* Handles user registration, login, token management, and password operations
* Uses JWT for access tokens and refresh tokens for session management
*/
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/db.js';
import crypto from 'crypto';
const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret-here-change-in-production';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const REFRESH_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60; // 7 days in seconds
const BCRYPT_ROUNDS = 12;
/**
* Register a new user
*/
export async function register({ email, password, name }) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
// Validate input
if (!email || !password) {
throw new Error('Email and password are required');
}
if (password.length < 8) {
throw new Error('Password must be at least 8 characters');
}
// Check if user already exists
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Generate email verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
const verificationExpires = now + (24 * 60 * 60); // 24 hours
// Create user
const userId = uuidv4();
db.prepare(`
INSERT INTO users (
id, email, name, password_hash,
email_verified, email_verification_token, email_verification_expires,
status, failed_login_attempts, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
email.toLowerCase(),
name || null,
passwordHash,
0, // email_verified
verificationToken,
verificationExpires,
'active',
0, // failed_login_attempts
now,
now
);
// TODO: Send verification email (implement email service)
console.log(`[Auth] User registered: ${email} (verification token: ${verificationToken})`);
return {
userId,
email: email.toLowerCase(),
name,
verificationToken // Return for testing, remove in production
};
}
/**
* Login user and issue tokens
*/
export async function login({ email, password, deviceInfo, ipAddress }) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
if (!email || !password) {
throw new Error('Email and password are required');
}
// Find user
const user = db.prepare(`
SELECT id, email, name, password_hash, status, email_verified,
failed_login_attempts, locked_until, is_system_admin
FROM users
WHERE email = ?
`).get(email.toLowerCase());
if (!user) {
throw new Error('Invalid email or password');
}
// Check if account is locked
if (user.locked_until && user.locked_until > now) {
const minutesLeft = Math.ceil((user.locked_until - now) / 60);
throw new Error(`Account is locked. Try again in ${minutesLeft} minutes`);
}
// Check if account is suspended
if (user.status === 'suspended') {
throw new Error('Account is suspended. Contact support');
}
if (user.status === 'deleted') {
throw new Error('Account not found');
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
// Increment failed login attempts
const failedAttempts = (user.failed_login_attempts || 0) + 1;
const lockUntil = failedAttempts >= 5 ? now + (15 * 60) : null; // Lock for 15 mins after 5 attempts
db.prepare(`
UPDATE users
SET failed_login_attempts = ?,
locked_until = ?
WHERE id = ?
`).run(failedAttempts, lockUntil, user.id);
if (lockUntil) {
throw new Error('Too many failed attempts. Account locked for 15 minutes');
}
throw new Error('Invalid email or password');
}
// Reset failed login attempts on successful login
db.prepare(`
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = ?
WHERE id = ?
`).run(now, user.id);
// Generate access token (JWT)
const accessToken = generateAccessToken(user);
// Generate refresh token
const refreshToken = await createRefreshToken({
userId: user.id,
deviceInfo,
ipAddress
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
emailVerified: Boolean(user.email_verified),
is_system_admin: Boolean(user.is_system_admin)
}
};
}
/**
* Refresh access token using refresh token
*/
export async function refreshAccessToken(refreshToken) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
if (!refreshToken) {
throw new Error('Refresh token is required');
}
// Hash the token to compare
const tokenHash = hashToken(refreshToken);
// Find refresh token
const token = db.prepare(`
SELECT id, user_id, expires_at, revoked
FROM refresh_tokens
WHERE token_hash = ?
`).get(tokenHash);
if (!token) {
throw new Error('Invalid refresh token');
}
if (token.revoked) {
throw new Error('Refresh token has been revoked');
}
if (token.expires_at < now) {
throw new Error('Refresh token has expired');
}
// Get user
const user = db.prepare(`
SELECT id, email, name, status, email_verified, is_system_admin
FROM users
WHERE id = ?
`).get(token.user_id);
if (!user || user.status !== 'active') {
throw new Error('User not found or inactive');
}
// Generate new access token
const accessToken = generateAccessToken(user);
return {
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name,
emailVerified: Boolean(user.email_verified),
is_system_admin: Boolean(user.is_system_admin)
}
};
}
/**
* Revoke refresh token (logout)
*/
export async function revokeRefreshToken(refreshToken) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
if (!refreshToken) {
return { success: true }; // Silently succeed if no token
}
const tokenHash = hashToken(refreshToken);
db.prepare(`
UPDATE refresh_tokens
SET revoked = 1,
revoked_at = ?
WHERE token_hash = ?
`).run(now, tokenHash);
return { success: true };
}
/**
* Revoke all refresh tokens for a user (logout all devices)
*/
export async function revokeAllUserTokens(userId) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
db.prepare(`
UPDATE refresh_tokens
SET revoked = 1,
revoked_at = ?
WHERE user_id = ? AND revoked = 0
`).run(now, userId);
return { success: true };
}
/**
* Request password reset
*/
export async function requestPasswordReset(email, ipAddress) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
// Find user
const user = db.prepare('SELECT id, email, status FROM users WHERE email = ?')
.get(email.toLowerCase());
// Always return success (don't reveal if email exists)
if (!user || user.status !== 'active') {
console.log(`[Auth] Password reset requested for non-existent/inactive user: ${email}`);
return { success: true };
}
// Generate reset token
const resetToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(resetToken);
const expiresAt = now + (60 * 60); // 1 hour
// Store reset token
const tokenId = uuidv4();
db.prepare(`
INSERT INTO password_reset_tokens (
id, user_id, token_hash, expires_at, used, ip_address, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tokenId, user.id, tokenHash, expiresAt, 0, ipAddress, now);
// TODO: Send password reset email
console.log(`[Auth] Password reset token for ${email}: ${resetToken}`);
return {
success: true,
resetToken // Return for testing, remove in production
};
}
/**
* Reset password using token
*/
export async function resetPassword(token, newPassword) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
if (!token || !newPassword) {
throw new Error('Token and new password are required');
}
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters');
}
const tokenHash = hashToken(token);
// Find reset token
const resetToken = db.prepare(`
SELECT id, user_id, expires_at, used
FROM password_reset_tokens
WHERE token_hash = ?
`).get(tokenHash);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
}
if (resetToken.used) {
throw new Error('Reset token has already been used');
}
if (resetToken.expires_at < now) {
throw new Error('Reset token has expired');
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
// Update password and mark token as used
db.transaction(() => {
db.prepare(`
UPDATE users
SET password_hash = ?,
failed_login_attempts = 0,
locked_until = NULL,
updated_at = ?
WHERE id = ?
`).run(passwordHash, now, resetToken.user_id);
db.prepare(`
UPDATE password_reset_tokens
SET used = 1,
used_at = ?
WHERE id = ?
`).run(now, resetToken.id);
// Revoke all refresh tokens for security
db.prepare(`
UPDATE refresh_tokens
SET revoked = 1,
revoked_at = ?
WHERE user_id = ? AND revoked = 0
`).run(now, resetToken.user_id);
})();
return { success: true };
}
/**
* Verify email with token
*/
export async function verifyEmail(token) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
if (!token) {
throw new Error('Verification token is required');
}
// Find user with this verification token
const user = db.prepare(`
SELECT id, email, email_verified, email_verification_expires
FROM users
WHERE email_verification_token = ?
`).get(token);
if (!user) {
throw new Error('Invalid verification token');
}
if (user.email_verified) {
return { success: true, message: 'Email already verified' };
}
if (user.email_verification_expires < now) {
throw new Error('Verification token has expired');
}
// Mark email as verified
db.prepare(`
UPDATE users
SET email_verified = 1,
email_verification_token = NULL,
email_verification_expires = NULL,
updated_at = ?
WHERE id = ?
`).run(now, user.id);
return {
success: true,
email: user.email
};
}
// ==================== Helper Functions ====================
/**
* Generate JWT access token
*/
function generateAccessToken(user) {
const payload = {
userId: user.id,
email: user.email,
emailVerified: Boolean(user.email_verified)
};
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
issuer: 'navidocs',
subject: user.id
});
}
/**
* Create refresh token
*/
async function createRefreshToken({ userId, deviceInfo, ipAddress }) {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
// Generate random token
const token = crypto.randomBytes(64).toString('hex');
const tokenHash = hashToken(token);
// Store in database
const tokenId = uuidv4();
const expiresAt = now + REFRESH_TOKEN_EXPIRES_IN;
db.prepare(`
INSERT INTO refresh_tokens (
id, user_id, token_hash, device_info, ip_address,
expires_at, revoked, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tokenId,
userId,
tokenHash,
deviceInfo || null,
ipAddress || null,
expiresAt,
0,
now
);
return token;
}
/**
* Hash token for storage
*/
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Verify JWT token
*/
export function verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return {
valid: true,
payload: decoded
};
} catch (error) {
return {
valid: false,
error: error.message
};
}
}
/**
* Get user by ID
*/
export function getUserById(userId) {
const db = getDb();
const user = db.prepare(`
SELECT id, email, name, status, email_verified, created_at, last_login_at
FROM users
WHERE id = ?
`).get(userId);
if (!user) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
status: user.status,
emailVerified: Boolean(user.email_verified),
createdAt: user.created_at,
lastLoginAt: user.last_login_at
};
}