Skip to content

Commit 2e836e0

Browse files
authored
Merge pull request #25 from techstartucalgary/backend
merge backend to main
2 parents f882137 + c926967 commit 2e836e0

File tree

9 files changed

+314
-7
lines changed

9 files changed

+314
-7
lines changed

shatter-backend/package-lock.json

Lines changed: 21 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shatter-backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
"license": "ISC",
1414
"description": "",
1515
"dependencies": {
16+
"bcryptjs": "^3.0.3",
1617
"dotenv": "^17.2.3",
1718
"express": "^5.1.0",
1819
"mongoose": "^8.19.2",
1920
"zod": "^4.1.12"
2021
},
2122
"devDependencies": {
2223
"@eslint/js": "^9.38.0",
24+
"@types/bcryptjs": "^2.4.6",
2325
"@types/express": "^5.0.5",
2426
"@types/node": "^24.9.2",
2527
"eslint": "^9.38.0",

shatter-backend/src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from 'express';
2-
import userRoutes from './routes/user_route';
2+
import userRoutes from './routes/user_route'; // these routes define how to handle requests to /api/users
3+
import authRoutes from './routes/auth_routes';
34

45
const app = express();
56

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

1213
app.use('/api/users', userRoutes);
14+
app.use('/api/auth', authRoutes);
1315

1416
export default app;
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { Request, Response } from 'express';
2+
import { User } from '../models/user_model';
3+
import { hashPassword, comparePassword } from '../utils/password_hash';
4+
5+
// Email validation regex
6+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
7+
8+
/**
9+
* POST /api/auth/signup
10+
* Create new user account
11+
*
12+
* @param req.body.name - User's display name
13+
* @param req.body.email - User's email
14+
* @param req.body.password - User's plain text password
15+
* @returns 201 with userId on success
16+
*/
17+
export const signup = async (req: Request, res: Response) => {
18+
try {
19+
// extract data from req body
20+
const { name, email, password } = req.body as {
21+
name?: string;
22+
email?: string;
23+
password?: string;
24+
};
25+
26+
// validate required fields
27+
if (!name || !email || !password) {
28+
return res.status(400).json({
29+
error: 'name, email and password are required'
30+
});
31+
}
32+
33+
// normalize email before validation
34+
const normalizedEmail = email.toLowerCase().trim();
35+
36+
// validate email format
37+
if (!EMAIL_REGEX.test(normalizedEmail)) {
38+
return res.status(400).json({
39+
error: 'Invalid email format'
40+
});
41+
}
42+
43+
// validate password length
44+
if (password.length < 8) {
45+
return res.status(400).json({
46+
error: 'Password must be at least 8 characters long'
47+
});
48+
}
49+
50+
// check if email already exists
51+
const existingUser = await User.findOne({ email: normalizedEmail }).lean();
52+
if (existingUser) {
53+
return res.status(409).json({
54+
error: 'Email already exists'
55+
});
56+
}
57+
58+
// hash the password
59+
const passwordHash = await hashPassword(password);
60+
61+
// create user in database
62+
// mongoose automatically adds createdAt, updatedAt, _id with User.create
63+
const newUser = await User.create({
64+
name,
65+
email: normalizedEmail,
66+
passwordHash
67+
});
68+
69+
// return success
70+
res.status(201).json({
71+
message: 'User created successfully',
72+
userId: newUser._id
73+
});
74+
75+
} catch (err: any) {
76+
console.error('POST /api/auth/signup error:', err);
77+
78+
// handle duplicate email error form MongoDB
79+
if (err?.code === 11000) {
80+
return res.status(409).json({
81+
error: 'Email already exists'
82+
});
83+
}
84+
85+
// Generic error Response
86+
res.status(500).json({
87+
error: 'Failed to create user'
88+
});
89+
}
90+
};
91+
92+
93+
/**
94+
* POST /api/auth/login
95+
* Authenticate user and log them in
96+
*
97+
* @param req.body.email - User's email
98+
* @param req.body.password - User's plain text password
99+
* @returns 200 with userId on success
100+
*/
101+
export const login = async (req: Request, res: Response) => {
102+
try {
103+
// 1 - extract data from req body
104+
const { email, password } = req.body as {
105+
email?: string;
106+
password?: string;
107+
};
108+
109+
// 2 - validate required fields
110+
if (!email || !password) {
111+
return res.status(400).json({
112+
error: 'Email and password are required'
113+
});
114+
}
115+
116+
// 3 - normalize email
117+
const normalizedEmail = email.toLowerCase().trim();
118+
119+
// 4 - validate email format
120+
if (!EMAIL_REGEX.test(normalizedEmail)) {
121+
return res.status(400).json({
122+
error: 'Invalid email format'
123+
});
124+
}
125+
126+
// 5 - find user by email
127+
const user = await User.findOne({ email: normalizedEmail })
128+
.select('+passwordHash'); // need this since queries don't return passwordHash by default
129+
130+
// 6 - check if user exists
131+
if (!user) {
132+
// for security purposes I won't include whether email exists or not
133+
return res.status(401).json({
134+
error: 'Invalid credentials'
135+
});
136+
}
137+
138+
// 7 - verify password
139+
const isPasswordValid = await comparePassword(password, user.passwordHash);
140+
141+
if (!isPasswordValid) {
142+
return res.status(401).json({
143+
error: 'Invalid credentials'
144+
});
145+
}
146+
147+
// 8 - update lastLogin stamp
148+
user.lastLogin = new Date();
149+
await user.save(); // save the updated user
150+
151+
// 9 - return success
152+
res.status(200).json({
153+
message: 'Login successful',
154+
userId: user._id
155+
// TODO: figure out a way to add JWT token here
156+
});
157+
158+
} catch (err: any) {
159+
console.error('POST /api/auth/login error:', err);
160+
161+
// Generic error Response
162+
res.status(500).json({
163+
error: 'Login failed'
164+
});
165+
}
166+
};
167+

shatter-backend/src/models/user_model.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { Schema, model } from 'mongoose';
99
export interface IUser {
1010
name: string;
1111
email: string;
12+
passwordHash: string;
13+
lastLogin?: Date;
14+
passwordChangedAt?: Date;
15+
createdAt?: Date;
16+
updatedAt?: Date;
1217
}
1318

1419
// Create the Mongoose Schema (the database blueprint)
@@ -26,8 +31,26 @@ const UserSchema = new Schema<IUser>(
2631
type: String,
2732
required: true,
2833
trim: true,
29-
lowercase: true, // converts all emails to lowercase before saving for consistency
30-
unique: true // enforce uniqueness, error 11000 if duplicate is detected
34+
lowercase: true,
35+
unique: true,
36+
index: true,
37+
match: [
38+
/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/,
39+
'Please provide a valid email address'
40+
]
41+
},
42+
passwordHash: {
43+
type: String,
44+
required: true,
45+
select: false // Don't return in queries by default
46+
},
47+
lastLogin: {
48+
type: Date,
49+
default: null
50+
},
51+
passwordChangedAt: {
52+
type: Date,
53+
default: null
3154
}
3255
},
3356
{
@@ -38,6 +61,13 @@ const UserSchema = new Schema<IUser>(
3861
}
3962
);
4063

64+
// Add middleware to auto-update passwordChangedAt
65+
UserSchema.pre('save', function (next) {
66+
if (this.isModified('passwordHash') && !this.isNew) {
67+
this.passwordChangedAt = new Date();
68+
}
69+
next();
70+
});
4171

4272
// create and export mongoose model
4373
// model is simply a wrapper around schema that gives access to MongoDB opeprations
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Router } from 'express';
2+
import { signup, login } from '../controllers/auth_controller';
3+
4+
const router = Router();
5+
6+
// POST /api/auth/signup - create new user account
7+
router.post('/signup', signup);
8+
9+
// POST /api/auth/login - authenticate user
10+
router.post('/login', login);
11+
12+
export default router;

shatter-backend/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ async function start() {
1616

1717
// start listening for incoming HTTP requests on chosen port
1818
app.listen(PORT, () => {
19-
console.log('Server running on http://localhost:${PORT}');
19+
console.log(`Server running on http://localhost:${PORT}`);
2020
});
2121
} catch (err) {
2222
console.error('Failed to start server:', err);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import bcrypt from 'bcryptjs';
2+
3+
// How many times to scramble the password (10 seems to be the standard)
4+
// the password will be hashed 2^10 = 1024 times
5+
const SALT_ROUNDS = 10
6+
7+
// we use export so other files can import and use this func
8+
// hashing takes quite some time so we use the async keyword
9+
// the function will return a promise that resolves to a string (hash)
10+
export const hashPassword = async (password: string): Promise<string> => {
11+
try {
12+
// 1- generate a random salt (unique random data)
13+
// 'await' ensures that this assignment is complete before moving on
14+
const salt = await bcrypt.genSalt(SALT_ROUNDS)
15+
16+
// 2- combine password with salt and hash it
17+
const hash = await bcrypt.hash(password, salt);
18+
19+
// return the hash (this is what we store in database)
20+
return hash;
21+
} catch (error) {
22+
console.error('Error hashing password:', error);
23+
throw new Error('Failed to hash password');
24+
}
25+
};
26+
27+
// takes the password user typed and stored hash
28+
// recall that bcrypt stores the salt inside the hash
29+
// the function internally extracts salt from hash
30+
// it then rehashes the typed password with same salt and compares
31+
export const comparePassword = async (
32+
password: string,
33+
hash: string,
34+
): Promise<boolean> => {
35+
try {
36+
// bcrypt extracts the salt from the hash and compares
37+
const isMatch = await bcrypt.compare(password, hash);
38+
return isMatch;
39+
} catch (error) {
40+
console.error('Error comparing passwords:', error);
41+
throw new Error('Failed to compare passwords');
42+
}
43+
};
44+
45+

0 commit comments

Comments
 (0)