48hr.email/application/auth-service.js
ClaraCrazy 598cea9b9c
[Feat]: Add User Registration
Add User table to sql, add user-repository, add registration and login routes, update config
2026-01-02 16:27:43 +01:00

246 lines
7.8 KiB
JavaScript

const bcrypt = require('bcrypt')
const debug = require('debug')('48hr-email:auth-service')
/**
* Authentication Service - Business logic for user authentication
* Handles registration, login, validation, and password management
*/
class AuthService {
constructor(userRepository, config) {
this.userRepository = userRepository
this.config = config
this.BCRYPT_ROUNDS = 12
}
/**
* Register a new user
* @param {string} username - Username (3-20 alphanumeric + underscore)
* @param {string} password - Password (min 8 chars, complexity requirements)
* @returns {Promise<{success: boolean, user?: Object, error?: string}>}
*/
async register(username, password) {
// Validate username
const usernameValidation = this.validateUsername(username)
if (!usernameValidation.valid) {
debug(`Registration failed: ${usernameValidation.error}`)
return { success: false, error: usernameValidation.error }
}
// Validate password
const passwordValidation = this.validatePassword(password)
if (!passwordValidation.valid) {
debug(`Registration failed: ${passwordValidation.error}`)
return { success: false, error: passwordValidation.error }
}
try {
// Hash password
const passwordHash = await this.hashPassword(password)
// Create user
const user = this.userRepository.createUser(username, passwordHash)
debug(`User registered successfully: ${username} (ID: ${user.id})`)
return {
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at
}
}
} catch (error) {
if (error.message === 'Username already exists') {
debug(`Registration failed: Username already exists: ${username}`)
return { success: false, error: 'Username already exists' }
}
debug(`Registration error: ${error.message}`)
return { success: false, error: 'Registration failed. Please try again.' }
}
}
/**
* Login user with username and password
* @param {string} username
* @param {string} password
* @returns {Promise<{success: boolean, user?: Object, error?: string}>}
*/
async login(username, password) {
if (!username || !password) {
debug('Login failed: Missing username or password')
return { success: false, error: 'Username and password are required' }
}
try {
// Get user from database
const user = this.userRepository.getUserByUsername(username)
if (!user) {
debug(`Login failed: User not found: ${username}`)
// Use generic error to prevent username enumeration
return { success: false, error: 'Invalid username or password' }
}
// Verify password
const isValid = await this.verifyPassword(password, user.password_hash)
if (!isValid) {
debug(`Login failed: Invalid password for user: ${username}`)
return { success: false, error: 'Invalid username or password' }
}
// Update last login
this.userRepository.updateLastLogin(user.id)
debug(`User logged in successfully: ${username} (ID: ${user.id})`)
return {
success: true,
user: {
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: Date.now()
}
}
} catch (error) {
debug(`Login error: ${error.message}`)
return { success: false, error: 'Login failed. Please try again.' }
}
}
/**
* Validate username format
* @param {string} username
* @returns {{valid: boolean, error?: string}}
*/
validateUsername(username) {
if (!username) {
return { valid: false, error: 'Username is required' }
}
if (typeof username !== 'string') {
return { valid: false, error: 'Username must be a string' }
}
const trimmed = username.trim()
if (trimmed.length < 3) {
return { valid: false, error: 'Username must be at least 3 characters' }
}
if (trimmed.length > 20) {
return { valid: false, error: 'Username must be at most 20 characters' }
}
// Only allow alphanumeric and underscore
const usernameRegex = /^[a-zA-Z0-9_]+$/
if (!usernameRegex.test(trimmed)) {
return { valid: false, error: 'Username can only contain letters, numbers, and underscores' }
}
return { valid: true }
}
/**
* Validate password strength
* @param {string} password
* @returns {{valid: boolean, error?: string}}
*/
validatePassword(password) {
if (!password) {
return { valid: false, error: 'Password is required' }
}
if (typeof password !== 'string') {
return { valid: false, error: 'Password must be a string' }
}
if (password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' }
}
if (password.length > 72) {
// Bcrypt max length
return { valid: false, error: 'Password must be at most 72 characters' }
}
// Check for at least one uppercase letter
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' }
}
// Check for at least one lowercase letter
if (!/[a-z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' }
}
// Check for at least one number
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain at least one number' }
}
return { valid: true }
}
/**
* Hash password using bcrypt
* @param {string} password
* @returns {Promise<string>} - Password hash
*/
async hashPassword(password) {
try {
const hash = await bcrypt.hash(password, this.BCRYPT_ROUNDS)
debug('Password hashed successfully')
return hash
} catch (error) {
debug(`Error hashing password: ${error.message}`)
throw new Error('Failed to hash password')
}
}
/**
* Verify password against hash
* @param {string} password
* @param {string} hash
* @returns {Promise<boolean>}
*/
async verifyPassword(password, hash) {
try {
const isValid = await bcrypt.compare(password, hash)
debug(`Password verification: ${isValid ? 'success' : 'failed'}`)
return isValid
} catch (error) {
debug(`Error verifying password: ${error.message}`)
return false
}
}
/**
* Get user for session (without sensitive data)
* @param {number} userId
* @returns {Object|null} - User session data
*/
getUserForSession(userId) {
try {
const user = this.userRepository.getUserById(userId)
if (!user) {
return null
}
return {
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: user.last_login
}
} catch (error) {
debug(`Error getting user for session: ${error.message}`)
return null
}
}
}
module.exports = AuthService