Skip to content

Commit

Permalink
Merge branch 'main' into maggie/course-units-api
Browse files Browse the repository at this point in the history
  • Loading branch information
mxc-maggiechen committed Oct 8, 2024
2 parents 3b4545a + 4abcb49 commit 945be1f
Show file tree
Hide file tree
Showing 14 changed files with 400 additions and 24 deletions.
16 changes: 16 additions & 0 deletions backend/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { NextFunction, Request, Response } from "express";
import AuthService from "../services/implementations/authService";
import UserService from "../services/implementations/userService";
import IAuthService from "../services/interfaces/authService";
import IUserService from "../services/interfaces/userService";
import { Role } from "../types/userTypes";

const authService: IAuthService = new AuthService(new UserService());
const userService: IUserService = new UserService();

export const getAccessToken = (req: Request): string | null => {
const authHeaderParts = req.headers.authorization?.split(" ");
Expand Down Expand Up @@ -76,3 +78,17 @@ export const isAuthorizedByEmail = (emailField: string) => {
return next();
};
};

export const isFirstTimeInvitedUser = () => {
return async (req: Request, res: Response, next: NextFunction) => {
const accessToken = getAccessToken(req);
const authorized =
accessToken && (await userService.isFirstTimeInvitedUser(accessToken));
if (!authorized) {
return res
.status(401)
.json({ error: "You are not a first-time invited user." });
}
return next();
};
};
24 changes: 24 additions & 0 deletions backend/middlewares/validators/authValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,27 @@ export const forgotPasswordRequestValidator = async (

return next();
};

export const updateTemporaryPasswordRequestValidator = async (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!validatePrimitive(req.body.newPassword, "string")) {
return res.status(400).send(getApiValidationError("newPassword", "string"));
}

return next();
};

export const updateUserStatusRequestValidator = async (
req: Request,
res: Response,
next: NextFunction,
) => {
if (!validatePrimitive(req.body.status, "string")) {
return res.status(400).send(getApiValidationError("status", "string"));
}

return next();
};
37 changes: 37 additions & 0 deletions backend/rest/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
isAuthorizedByEmail,
isAuthorizedByUserId,
isAuthorizedByRole,
isFirstTimeInvitedUser,
} from "../middlewares/auth";
import {
loginRequestValidator,
signupRequestValidator,
inviteAdminRequestValidator,
forgotPasswordRequestValidator,
updateTemporaryPasswordRequestValidator,
updateUserStatusRequestValidator,
} from "../middlewares/validators/authValidators";
import nodemailerConfig from "../nodemailer.config";
import AuthService from "../services/implementations/authService";
Expand Down Expand Up @@ -214,4 +217,38 @@ authRouter.post(
},
);

authRouter.post(
"/updateTemporaryPassword",
updateTemporaryPasswordRequestValidator,
isFirstTimeInvitedUser(),
async (req, res) => {
try {
const accessToken = getAccessToken(req)!;
const newAccessToken = await authService.changeUserPassword(
accessToken,
req.body.newPassword,
);
res.status(200).json({
accessToken: newAccessToken,
});
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
},
);

authRouter.post(
"/updateUserStatus",
updateUserStatusRequestValidator,
async (req, res) => {
try {
const accessToken = getAccessToken(req)!;
await userService.changeUserStatus(accessToken, req.body.status);
res.status(204).send();
} catch (error: unknown) {
res.status(500).json({ error: getErrorMessage(error) });
}
},
);

export default authRouter;
60 changes: 42 additions & 18 deletions backend/services/implementations/authService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as firebaseAdmin from "firebase-admin";
import { ObjectId } from "mongoose";

import IAuthService from "../interfaces/authService";
import IEmailService from "../interfaces/emailService";
import IUserService from "../interfaces/userService";
Expand Down Expand Up @@ -136,25 +137,48 @@ class AuthService implements IAuthService {
throw new Error(errorMessage);
}

const emailVerificationLink = await firebaseAdmin
.auth()
.generateEmailVerificationLink(email);
try {
const emailVerificationLink = await firebaseAdmin
.auth()
.generateEmailVerificationLink(email);

const emailBody = `Hello,<br> <br>
You have been invited as an administrator to Smart Saving, Smart Spending.
<br> <br>
Please click the following link to verify your email and activate your account.
<strong>This link is only valid for 1 hour.</strong>
<br> <br>
<a href=${emailVerificationLink}>Verify email</a>
<br> <br>
To log in for the first time, use your email address and the following temporary password: <strong>${temporaryPassword}</strong>`;

await this.emailService.sendEmail(
email,
"Administrator Invitation: Smart Saving, Smart Spending",
emailBody,
);
const emailBody = `Hello,<br> <br>
You have been invited as an administrator to Smart Saving, Smart Spending.
<br> <br>
Please click the following link to verify your email and activate your account.
<strong>This link is only valid for 1 hour.</strong>
<br> <br>
<a href=${emailVerificationLink}>Verify email</a>
<br> <br>
To log in for the first time, use your email address and the following temporary password: <strong>${temporaryPassword}</strong>`;

await this.emailService.sendEmail(
email,
"Administrator Invitation: Smart Saving, Smart Spending",
emailBody,
);
} catch (error: unknown) {
Logger.error(
`Failed to invite new administrator. Reason = ${getErrorMessage(
error,
)}`,
);
throw error;
}
}

async changeUserPassword(
accessToken: string,
newPassword: string,
): Promise<string> {
try {
return await FirebaseRestClient.changePassword(accessToken, newPassword);
} catch (error: unknown) {
Logger.error(
`Failed to change user's password. Reason = ${getErrorMessage(error)}`,
);
throw error;
}
}

async isAuthorizedByRole(
Expand Down
54 changes: 51 additions & 3 deletions backend/services/implementations/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import MgUser, { User } from "../../models/user.mgmodel";
import {
CreateUserDTO,
Role,
Status,
UpdateUserDTO,
UserDTO,
} from "../../types/userTypes";
Expand All @@ -25,7 +26,7 @@ const getMongoUserByAuthId = async (authId: string): Promise<User> => {

class UserService implements IUserService {
/* eslint-disable class-methods-use-this */
async getUserById(userId: string): Promise<UserDTO> {
async getUserById(userId: string | ObjectId): Promise<UserDTO> {
let user: User | null;
let firebaseUser: firebaseAdmin.auth.UserRecord;

Expand Down Expand Up @@ -183,15 +184,23 @@ class UserService implements IUserService {
};
}

async updateUserById(userId: string, user: UpdateUserDTO): Promise<UserDTO> {
async updateUserById(
userId: ObjectId | string,
user: UpdateUserDTO,
): Promise<UserDTO> {
let oldUser: User | null;
let updatedFirebaseUser: firebaseAdmin.auth.UserRecord;

try {
// must explicitly specify runValidators when updating through findByIdAndUpdate
oldUser = await MgUser.findByIdAndUpdate(
userId,
{ firstName: user.firstName, lastName: user.lastName, role: user.role },
{
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
status: user.status,
},
{ runValidators: true },
);

Expand All @@ -212,6 +221,7 @@ class UserService implements IUserService {
firstName: oldUser.firstName,
lastName: oldUser.lastName,
role: oldUser.role,
status: oldUser.status,
},
{ runValidators: true },
);
Expand Down Expand Up @@ -343,6 +353,44 @@ class UserService implements IUserService {
}
return userDtos;
}

async isFirstTimeInvitedUser(accessToken: string): Promise<boolean> {
try {
const decodedIdToken: firebaseAdmin.auth.DecodedIdToken =
await firebaseAdmin.auth().verifyIdToken(accessToken, true);
const { status } = await getMongoUserByAuthId(decodedIdToken.uid);
return status === "Invited";
} catch (error: unknown) {
Logger.error(
`Failed to verify user is first time invited user. Reason = ${getErrorMessage(
error,
)}`,
);
throw error;
}
}

async changeUserStatus(
accessToken: string,
newStatus: Status,
): Promise<void> {
try {
const decodedIdToken: firebaseAdmin.auth.DecodedIdToken =
await firebaseAdmin.auth().verifyIdToken(accessToken, true);
const tokenUserId = await this.getUserIdByAuthId(decodedIdToken.uid);
const currentUser = await this.getUserById(tokenUserId);
const updatedUser: UpdateUserDTO = {
...currentUser,
status: newStatus,
};
await this.updateUserById(tokenUserId, updatedUser);
} catch (error: unknown) {
Logger.error(
`Failed to change user status. Reason = ${getErrorMessage(error)}`,
);
throw error;
}
}
}

export default UserService;
9 changes: 8 additions & 1 deletion backend/services/interfaces/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,19 @@ interface IAuthService {

/**
* Sends an email invitation to an invited administrator with the temporary password specified
* @param email email of new administrator invited
* @param email email address of new administrator invited
* @param temporaryPassword the new administrator's temporary password
* @throws Error if unable to generate link or send email
*/
sendAdminInvite(email: string, temporaryPassword: string): Promise<void>;

/**
* Changes a user's password
* @param email the user's email address
* @param newPassword new password chosen to replace the user's old password
*/
changeUserPassword(accessToken: string, newPassword: string): Promise<string>;

/**
* Determine if the provided access token is valid and authorized for at least
* one of the specified roles
Expand Down
15 changes: 15 additions & 0 deletions backend/services/interfaces/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ObjectId } from "mongoose";
import {
CreateUserDTO,
Role,
Status,
UpdateUserDTO,
UserDTO,
} from "../../types/userTypes";
Expand Down Expand Up @@ -94,6 +95,20 @@ interface IUserService {
* @returns an Array of UserDtos that all have the corresponding role
*/
getUsersByRole(role: Role): Promise<Array<UserDTO>>;

/**
* Determine if the provided access token is valid and the user to which it belongs has the specified status
* @param accessToken user's access token
* @param status status to check for match
*/
isFirstTimeInvitedUser(accessToken: string): Promise<boolean>;

/**
* Update the user's status to the specified new status value
* @param accessToken user's access token
* @param newStatus status to update to
*/
changeUserStatus(accessToken: string, newStatus: Status): Promise<void>;
}

export default IUserService;
45 changes: 45 additions & 0 deletions backend/utilities/firebaseRestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const FIREBASE_SIGN_IN_URL =
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword";
const FIREBASE_REFRESH_TOKEN_URL =
"https://securetoken.googleapis.com/v1/token";
const FIREBASE_CHANGE_PASSWORD_URL =
"https://identitytoolkit.googleapis.com/v1/accounts:update";

type PasswordSignInResponse = {
idToken: string;
Expand All @@ -19,6 +21,13 @@ type PasswordSignInResponse = {
registered: boolean;
};

type ChangePasswordResponse = {
localId: string;
email: string;
idToken: string;
emailVerified: boolean;
};

type RefreshTokenResponse = {
expires_in: string;
token_type: string;
Expand Down Expand Up @@ -117,6 +126,42 @@ const FirebaseRestClient = {
refreshToken: (responseJson as RefreshTokenResponse).refresh_token,
};
},

changePassword: async (
accessToken: string,
newPassword: string,
): Promise<string> => {
const response: Response = await fetch(
`${FIREBASE_CHANGE_PASSWORD_URL}?key=${process.env.FIREBASE_WEB_API_KEY}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
idToken: accessToken,
password: newPassword,
}),
},
);

const responseJson: ChangePasswordResponse | RequestError =
await response.json();

if (!response.ok) {
const errorMessage = [
"Failed to change password via Firebase REST API, status code =",
`${response.status},`,
"error message =",
(responseJson as RequestError).error.message,
];
Logger.error(errorMessage.join(" "));

throw new Error("Failed to change password via Firebase REST API");
}

return (responseJson as ChangePasswordResponse).idToken;
},
};

export default FirebaseRestClient;
Loading

0 comments on commit 945be1f

Please sign in to comment.