diff --git a/shatter-backend/package-lock.json b/shatter-backend/package-lock.json index a80d3cc..b537f61 100644 --- a/shatter-backend/package-lock.json +++ b/shatter-backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", "express": "^5.1.0", "mongoose": "^8.19.2", @@ -16,6 +17,7 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.5", "@types/node": "^24.9.2", "eslint": "^9.38.0", @@ -390,6 +392,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -925,6 +934,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2040,9 +2058,9 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/shatter-backend/package.json b/shatter-backend/package.json index c3179ef..b3696eb 100644 --- a/shatter-backend/package.json +++ b/shatter-backend/package.json @@ -13,6 +13,7 @@ "license": "ISC", "description": "", "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", "express": "^5.1.0", "mongoose": "^8.19.2", @@ -20,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.38.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.5", "@types/node": "^24.9.2", "eslint": "^9.38.0", diff --git a/shatter-backend/src/app.ts b/shatter-backend/src/app.ts index f849a26..11e7050 100644 --- a/shatter-backend/src/app.ts +++ b/shatter-backend/src/app.ts @@ -1,5 +1,6 @@ import express from 'express'; -import userRoutes from './routes/user_route'; +import userRoutes from './routes/user_route'; // these routes define how to handle requests to /api/users +import authRoutes from './routes/auth_routes'; const app = express(); @@ -10,5 +11,6 @@ app.get('/', (_req, res) => { }); app.use('/api/users', userRoutes); +app.use('/api/auth', authRoutes); export default app; diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts new file mode 100644 index 0000000..13bba1f --- /dev/null +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -0,0 +1,167 @@ +import { Request, Response } from 'express'; +import { User } from '../models/user_model'; +import { hashPassword, comparePassword } from '../utils/password_hash'; + +// Email validation regex +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +/** + * POST /api/auth/signup + * Create new user account + * + * @param req.body.name - User's display name + * @param req.body.email - User's email + * @param req.body.password - User's plain text password + * @returns 201 with userId on success + */ +export const signup = async (req: Request, res: Response) => { + try { + // extract data from req body + const { name, email, password } = req.body as { + name?: string; + email?: string; + password?: string; + }; + + // validate required fields + if (!name || !email || !password) { + return res.status(400).json({ + error: 'name, email and password are required' + }); + } + + // normalize email before validation + const normalizedEmail = email.toLowerCase().trim(); + + // validate email format + if (!EMAIL_REGEX.test(normalizedEmail)) { + return res.status(400).json({ + error: 'Invalid email format' + }); + } + + // validate password length + if (password.length < 8) { + return res.status(400).json({ + error: 'Password must be at least 8 characters long' + }); + } + + // check if email already exists + const existingUser = await User.findOne({ email: normalizedEmail }).lean(); + if (existingUser) { + return res.status(409).json({ + error: 'Email already exists' + }); + } + + // hash the password + const passwordHash = await hashPassword(password); + + // create user in database + // mongoose automatically adds createdAt, updatedAt, _id with User.create + const newUser = await User.create({ + name, + email: normalizedEmail, + passwordHash + }); + + // return success + res.status(201).json({ + message: 'User created successfully', + userId: newUser._id + }); + + } catch (err: any) { + console.error('POST /api/auth/signup error:', err); + + // handle duplicate email error form MongoDB + if (err?.code === 11000) { + return res.status(409).json({ + error: 'Email already exists' + }); + } + + // Generic error Response + res.status(500).json({ + error: 'Failed to create user' + }); + } +}; + + +/** + * POST /api/auth/login + * Authenticate user and log them in + * + * @param req.body.email - User's email + * @param req.body.password - User's plain text password + * @returns 200 with userId on success + */ +export const login = async (req: Request, res: Response) => { + try { + // 1 - extract data from req body + const { email, password } = req.body as { + email?: string; + password?: string; + }; + + // 2 - validate required fields + if (!email || !password) { + return res.status(400).json({ + error: 'Email and password are required' + }); + } + + // 3 - normalize email + const normalizedEmail = email.toLowerCase().trim(); + + // 4 - validate email format + if (!EMAIL_REGEX.test(normalizedEmail)) { + return res.status(400).json({ + error: 'Invalid email format' + }); + } + + // 5 - find user by email + const user = await User.findOne({ email: normalizedEmail }) + .select('+passwordHash'); // need this since queries don't return passwordHash by default + + // 6 - check if user exists + if (!user) { + // for security purposes I won't include whether email exists or not + return res.status(401).json({ + error: 'Invalid credentials' + }); + } + + // 7 - verify password + const isPasswordValid = await comparePassword(password, user.passwordHash); + + if (!isPasswordValid) { + return res.status(401).json({ + error: 'Invalid credentials' + }); + } + + // 8 - update lastLogin stamp + user.lastLogin = new Date(); + await user.save(); // save the updated user + + // 9 - return success + res.status(200).json({ + message: 'Login successful', + userId: user._id + // TODO: figure out a way to add JWT token here + }); + + } catch (err: any) { + console.error('POST /api/auth/login error:', err); + + // Generic error Response + res.status(500).json({ + error: 'Login failed' + }); + } +}; + diff --git a/shatter-backend/src/models/user_model.ts b/shatter-backend/src/models/user_model.ts index baf8986..23c1633 100644 --- a/shatter-backend/src/models/user_model.ts +++ b/shatter-backend/src/models/user_model.ts @@ -9,6 +9,11 @@ import { Schema, model } from 'mongoose'; export interface IUser { name: string; email: string; + passwordHash: string; + lastLogin?: Date; + passwordChangedAt?: Date; + createdAt?: Date; + updatedAt?: Date; } // Create the Mongoose Schema (the database blueprint) @@ -26,8 +31,26 @@ const UserSchema = new Schema( type: String, required: true, trim: true, - lowercase: true, // converts all emails to lowercase before saving for consistency - unique: true // enforce uniqueness, error 11000 if duplicate is detected + lowercase: true, + unique: true, + index: true, + match: [ + /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, + 'Please provide a valid email address' + ] + }, + passwordHash: { + type: String, + required: true, + select: false // Don't return in queries by default + }, + lastLogin: { + type: Date, + default: null + }, + passwordChangedAt: { + type: Date, + default: null } }, { @@ -38,6 +61,13 @@ const UserSchema = new Schema( } ); +// Add middleware to auto-update passwordChangedAt +UserSchema.pre('save', function (next) { + if (this.isModified('passwordHash') && !this.isNew) { + this.passwordChangedAt = new Date(); + } + next(); +}); // create and export mongoose model // model is simply a wrapper around schema that gives access to MongoDB opeprations diff --git a/shatter-backend/src/routes/auth_routes.ts b/shatter-backend/src/routes/auth_routes.ts new file mode 100644 index 0000000..5db96cd --- /dev/null +++ b/shatter-backend/src/routes/auth_routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { signup, login } from '../controllers/auth_controller'; + +const router = Router(); + +// POST /api/auth/signup - create new user account +router.post('/signup', signup); + +// POST /api/auth/login - authenticate user +router.post('/login', login); + +export default router; diff --git a/shatter-backend/src/server.ts b/shatter-backend/src/server.ts index c6acbad..257ba45 100644 --- a/shatter-backend/src/server.ts +++ b/shatter-backend/src/server.ts @@ -16,7 +16,7 @@ async function start() { // start listening for incoming HTTP requests on chosen port app.listen(PORT, () => { - console.log('Server running on http://localhost:${PORT}'); + console.log(`Server running on http://localhost:${PORT}`); }); } catch (err) { console.error('Failed to start server:', err); diff --git a/shatter-backend/src/utils/password_hash.ts b/shatter-backend/src/utils/password_hash.ts new file mode 100644 index 0000000..1051afc --- /dev/null +++ b/shatter-backend/src/utils/password_hash.ts @@ -0,0 +1,45 @@ +import bcrypt from 'bcryptjs'; + +// How many times to scramble the password (10 seems to be the standard) +// the password will be hashed 2^10 = 1024 times +const SALT_ROUNDS = 10 + +// we use export so other files can import and use this func +// hashing takes quite some time so we use the async keyword +// the function will return a promise that resolves to a string (hash) +export const hashPassword = async (password: string): Promise => { + try { + // 1- generate a random salt (unique random data) + // 'await' ensures that this assignment is complete before moving on + const salt = await bcrypt.genSalt(SALT_ROUNDS) + + // 2- combine password with salt and hash it + const hash = await bcrypt.hash(password, salt); + + // return the hash (this is what we store in database) + return hash; + } catch (error) { + console.error('Error hashing password:', error); + throw new Error('Failed to hash password'); + } +}; + +// takes the password user typed and stored hash +// recall that bcrypt stores the salt inside the hash +// the function internally extracts salt from hash +// it then rehashes the typed password with same salt and compares +export const comparePassword = async ( + password: string, + hash: string, +): Promise => { + try { + // bcrypt extracts the salt from the hash and compares + const isMatch = await bcrypt.compare(password, hash); + return isMatch; + } catch (error) { + console.error('Error comparing passwords:', error); + throw new Error('Failed to compare passwords'); + } +}; + + diff --git a/shatter-backend/src/utils/test_password.ts b/shatter-backend/src/utils/test_password.ts new file mode 100644 index 0000000..e8ff743 --- /dev/null +++ b/shatter-backend/src/utils/test_password.ts @@ -0,0 +1,31 @@ +import { hashPassword, comparePassword } from './password_hash'; + +async function testPasswordHashing() { + console.log('🧪 Testing Password Hashing...\n'); + + // Test 1: Hash a password + const plainPassword = 'mySecretPassword123'; + console.log('Original password:', plainPassword); + + const hashedPassword = await hashPassword(plainPassword); + console.log('Hashed password:', hashedPassword); + console.log('Hash length:', hashedPassword.length, 'characters\n'); + + // Test 2: Verify correct password + const isCorrect = await comparePassword(plainPassword, hashedPassword); + console.log('Testing correct password:', isCorrect ? 'PASS' : 'FAIL'); + + // Test 3: Verify wrong password + const isWrong = await comparePassword('wrongPassword', hashedPassword); + console.log('Testing wrong password:', !isWrong ? 'PASS' : 'FAIL'); + + // Test 4: Same password hashes differently each time (salt!) + const hash1 = await hashPassword(plainPassword); + const hash2 = await hashPassword(plainPassword); + console.log('\nSame password, different hashes (proves salt works):'); + console.log('Hash 1:', hash1); + console.log('Hash 2:', hash2); + console.log('Are they different?', hash1 !== hash2 ? 'YES' : 'NO'); +} + +testPasswordHashing();