Skip to content
24 changes: 21 additions & 3 deletions shatter-backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions shatter-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
"license": "ISC",
"description": "",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mongoose": "^8.19.2",
"zod": "^4.1.12"
},
"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",
Expand Down
4 changes: 3 additions & 1 deletion shatter-backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -10,5 +11,6 @@ app.get('/', (_req, res) => {
});

app.use('/api/users', userRoutes);
app.use('/api/auth', authRoutes);

export default app;
167 changes: 167 additions & 0 deletions shatter-backend/src/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
@@ -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'
});
}
};

34 changes: 32 additions & 2 deletions shatter-backend/src/models/user_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -26,8 +31,26 @@ const UserSchema = new Schema<IUser>(
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
}
},
{
Expand All @@ -38,6 +61,13 @@ const UserSchema = new Schema<IUser>(
}
);

// 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
Expand Down
12 changes: 12 additions & 0 deletions shatter-backend/src/routes/auth_routes.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion shatter-backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions shatter-backend/src/utils/password_hash.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<boolean> => {
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');
}
};


Loading