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
530 lines
12 KiB
JavaScript
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
|
|
};
|
|
}
|