Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 27 additions & 58 deletions server/src/controllers/AuthenticationController.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,57 @@
import { Request, Response } from 'express';
import { GoogleOAuthServiceType, GoogleOAuthUserInfo, TokenServiceType } from '../services';
import { UserRepository } from '../repositories';
import { ResponseError, AuthenticatedUser } from '../models';
import { User } from '../models';
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../errors';
import * as Sentry from '@sentry/node';

export interface AuthenticationControllerType {
login(req: Request, res: Response): Promise<void>;
logout(req: Request, res: Response): Promise<void>;
getSession(req: Request, res: Response): Promise<void>;
export interface LoginParams {
authCode: string;
redirectURI: string;
}

export const TOKEN_COOKIE_NAME = 'dojo_token';
export interface LoginResult {
user: User;
token: string;
}

export interface AuthenticationControllerType {
login(params: LoginParams): Promise<LoginResult>;
}

/**
* A class provides methods for handling user authentication,
* including login, logout, and session retrieval.
*
* @class
* */
* Handles user authentication: verifies Google OAuth credentials, ensures the
* user is authorized to access the system, and issues a JWT for the session.
*/
export class AuthenticationController implements AuthenticationControllerType {
private readonly userRepository: UserRepository;
private readonly googleOAuthService: GoogleOAuthServiceType;
private readonly tokenService: TokenServiceType;
private readonly tokenExpirationInDays: number;

constructor(
userRepository: UserRepository,
googleOAuthService: GoogleOAuthServiceType,
tokenService: TokenServiceType,
tokenExpirationInDays: number
tokenService: TokenServiceType
) {
this.userRepository = userRepository;
this.googleOAuthService = googleOAuthService;
this.tokenService = tokenService;
this.tokenExpirationInDays = tokenExpirationInDays;
}

async login(req: Request, res: Response) {
const authCode = req.body.authCode?.trim();
const redirectURI = req.body.redirectURI?.trim();
async login({ authCode, redirectURI }: LoginParams): Promise<LoginResult> {
if (!authCode || !redirectURI) {
res.status(400).json(new ResponseError('Code and redirectURI are required'));
return;
throw new BadRequestError('Code and redirectURI are required');
}

if (!this.isValidRedirectURI(redirectURI)) {
res.status(400).json(new ResponseError('Invalid redirectURI'));
return;
throw new BadRequestError('Invalid redirectURI');
}

// Verify user identity against Google OAuth service
let oauthUser: GoogleOAuthUserInfo;
try {
// 1. Exchange authentication code for access token
let googleAccessToken = await this.googleOAuthService.exchangeAuthCodeForToken(authCode, redirectURI);

// 2. Get user info from Google
oauthUser = await this.googleOAuthService.getUserInfo(googleAccessToken);

// 3. Revoke Google OAuth token - we don't use it anymore.
// Revoke Google OAuth token - we don't use it anymore.
// Do not wait for the response. Continue with login process.
this.googleOAuthService.revokeToken(googleAccessToken);
googleAccessToken = '';
Expand All @@ -67,52 +60,28 @@ export class AuthenticationController implements AuthenticationControllerType {
throw new Error('Could not verify google auth code');
}
} catch (error) {
res.status(401).json(new ResponseError('Could not verify user'));
Sentry.captureException(error);
return;
throw new UnauthorizedError('Could not verify user');
}

// Check if the user is allowed to access to the system
const user = await this.userRepository.findUserByEmail(oauthUser.email);
if (!user || !user.isActive) {
res.status(403).json(new ResponseError('Not allowed to login'));
Sentry.setUser({ email: oauthUser.email });
Sentry.captureMessage(`Attempt to login with unauthorized user`, 'warning');
return;
throw new ForbiddenError('Not allowed to login');
}

// Login Successful! Generate and send JWT token
const jwtToken = this.tokenService.generateAccessToken(user);

// Set the token in a cookie
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
res.cookie(TOKEN_COOKIE_NAME, jwtToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 24 * this.tokenExpirationInDays,
});

res.status(200).json(user);
}

async getSession(req: Request, res: Response): Promise<void> {
const user = res.locals.user as AuthenticatedUser;
res.status(200).json(user);
const token = this.tokenService.generateAccessToken(user);
return { user, token };
}

async logout(req: Request, res: Response): Promise<void> {
res.clearCookie(TOKEN_COOKIE_NAME);
res.status(204).end();
}

private isValidRedirectURI(redirectURI: string) {
private isValidRedirectURI(redirectURI: string): boolean {
const allowedHosts = ['localhost', 'dojo-test.hackyourfuture.net', 'dojo.hackyourfuture.net'];

try {
const url = new URL(redirectURI);
return allowedHosts.includes(url.hostname);
} catch (error) {
} catch {
return false;
}
}
Expand Down
20 changes: 9 additions & 11 deletions server/src/controllers/CohortsController.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Request, Response } from 'express';
import { TraineesRepository } from '../repositories';
import { LearningStatus, Track, calculateAverageTestScore, Trainee } from '../models';

interface Cohort {
export interface Cohort {
cohort: number | null;
trainees: TraineeSummary[];
}
Expand All @@ -24,8 +23,13 @@ interface TraineeSummary {
averageTestScore: number | null;
}

export interface GetCohortsParams {
start: number;
end: number;
}

export interface CohortsControllerType {
getCohorts(req: Request, res: Response): Promise<void>;
getCohorts(params: GetCohortsParams): Promise<Cohort[]>;
}

export class CohortsController implements CohortsControllerType {
Expand All @@ -34,13 +38,7 @@ export class CohortsController implements CohortsControllerType {
this.traineesRepository = traineesRepository;
}

async getCohorts(req: Request, res: Response) {
const MAX_POSSIBLE_COHORT = 999;
let start = Number.parseInt(req.query.start as string) || 0;
let end = Number.parseInt(req.query.end as string) || MAX_POSSIBLE_COHORT;
start = Math.max(start, 0);
end = Math.min(end, 999);

async getCohorts({ start, end }: GetCohortsParams): Promise<Cohort[]> {
const trainees = await this.traineesRepository.getTraineesByCohort(start, end, true);

// Group trainees by cohort
Expand All @@ -58,7 +56,7 @@ export class CohortsController implements CohortsControllerType {
trainees: (trainees ?? []).map(this.getTraineeSummary.bind(this)),
};
});
res.status(200).json(result);
return result;
}

private getTraineeSummary(trainee: Trainee): TraineeSummary {
Expand Down
36 changes: 21 additions & 15 deletions server/src/controllers/DashboardController.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { Request, Response } from 'express';
export interface Distribution {
label: string;
value: number;
percent: number;
}

export interface DashboardData {
demographics: {
genderDistribution: Distribution[];
countryOfOrigin: Distribution[];
};
program: {
graduations: Distribution[];
employment: Distribution[];
};
}

export interface DashboardControllerType {
getDashboard(req: Request, res: Response): Promise<void>;
getDashboard(): Promise<DashboardData>;
}

/**
* A class provides methods for handling dashboard operations.
*
* @class
* */
* Provides aggregated statistics for the dashboard view.
*/
export class DashboardController implements DashboardControllerType {
constructor() {}

/**
* Retrieve the dashboard data.
*/
async getDashboard(req: Request, res: Response) {
const response = {
async getDashboard(): Promise<DashboardData> {
return {
demographics: {
genderDistribution: [
{ label: 'Man', value: 214, percent: 57.1 },
Expand All @@ -43,7 +51,5 @@ export class DashboardController implements DashboardControllerType {
],
},
};

res.status(200).json(response);
}
}
43 changes: 18 additions & 25 deletions server/src/controllers/GeographyController.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { GeographyRepository } from '../repositories';

export interface CountryResult {
name: string;
flag: string;
}

export interface SearchGeographyParams {
query: string;
limit: number | null;
}

export interface GeographyControllerType {
getCountries(req: Request, res: Response, next: NextFunction): Promise<void>;
getCities(req: Request, res: Response, next: NextFunction): Promise<void>;
getCountries(params: SearchGeographyParams): Promise<CountryResult[]>;
getCities(params: SearchGeographyParams): Promise<string[]>;
}

export class GeographyController implements GeographyControllerType {
Expand All @@ -12,29 +21,13 @@ export class GeographyController implements GeographyControllerType {
this.geographyRepository = geographyRepository;
}

async getCountries(req: Request, res: Response, next: NextFunction): Promise<void> {
const query = (req.query.q as string) ?? '';
const limit = Number.parseInt(req.query.limit as string) || null;
try {
const countries = await this.geographyRepository.searchCountry(query, limit);
const jsonResponse = countries.map(({ name, flag }) => {
return { name, flag };
});
res.json(jsonResponse);
} catch (error) {
next(error);
}
async getCountries({ query, limit }: SearchGeographyParams): Promise<CountryResult[]> {
const countries = await this.geographyRepository.searchCountry(query, limit);
return countries.map(({ name, flag }) => ({ name, flag }));
}

async getCities(req: Request, res: Response, next: NextFunction): Promise<void> {
const query = (req.query.q as string) ?? '';
const limit = Number.parseInt(req.query.limit as string) || null;
try {
const cities = await this.geographyRepository.searchCity(query, limit);
const jsonResponse = cities.map((city) => city.name);
res.json(jsonResponse);
} catch (error) {
next(error);
}
async getCities({ query, limit }: SearchGeographyParams): Promise<string[]> {
const cities = await this.geographyRepository.searchCity(query, limit);
return cities.map((city) => city.name);
}
}
Loading
Loading