Skip to content

Commit

Permalink
store jwt and add it in auth header for api requests
Browse files Browse the repository at this point in the history
  • Loading branch information
wheelsandcogs committed Sep 2, 2024
1 parent 8e151d9 commit a6edd2a
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 57 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"rules": {
"prettier/prettier": 2,
"no-console": 0,
"no-process-env": 0
"no-process-env": 0,
"line-comment-position": 0
},
"globals": {
"NodeJS": true
Expand Down
5 changes: 5 additions & 0 deletions src/interfaces/authed-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Request } from 'express';

export interface AuthedRequest extends Request {
jwt?: string;
}
2 changes: 1 addition & 1 deletion src/interfaces/jwt-payload-with-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import { JwtPayload } from 'jsonwebtoken';
import { User } from './user.interface';

export interface JWTPayloadWithUser extends JwtPayload {
user: User;
user?: User;
}
34 changes: 21 additions & 13 deletions src/middleware/ensure-authenticated.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import { RequestHandler } from 'express';
import JWT from 'jsonwebtoken';

import { AuthedRequest } from '../interfaces/authed-request';
import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user';
import { logger } from '../utils/logger';

export const ensureAuthenticated: RequestHandler = (req, res, next) => {
export const ensureAuthenticated: RequestHandler = (req: AuthedRequest, res, next) => {
logger.debug('checking if user is authenticated...');

if (!req.cookies.jwt) {
logger.error('JWT cookie not found');
return res.redirect('/auth/login');
}
try {
if (!req.cookies.jwt) {
throw new Error('JWT cookie not found');
}

const secret = process.env.JWT_SECRET || '';
const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser;
const secret = process.env.JWT_SECRET || '';
const token = req.cookies.jwt;
const decoded = JWT.verify(token, secret) as JWTPayloadWithUser;

if (decoded.exp && decoded.exp <= Date.now() / 1000) {
logger.error('JWT token has expired');
return res.redirect('/auth/login');
}
if (decoded.exp && decoded.exp <= Date.now() / 1000) {
throw new Error('JWT token has expired');
}

req.user = decoded.user;
logger.info('user is authenticated');
// store the token string as we need it for the auth header in the API requests
req.jwt = token;
req.user = decoded.user;
logger.info('user is authenticated');
} catch (err) {
logger.error(err);
res.status(401);
return res.redirect('/auth/login?error=unauthenticated');
}

return next();
};
7 changes: 4 additions & 3 deletions src/routes/healthcheck.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Router } from 'express';

import { API } from '../services/api';
import { StatsWalesApi } from '../services/stats-wales-api';
import { logger } from '../utils/logger';

const APIInstance = new API();

export const healthcheck = Router();

healthcheck.get('/', async (req, res) => {
const lang = req.i18n.language || 'en-GB';
const APIInstance = new StatsWalesApi(lang);
logger.info(`Healthcheck requested in ${lang}`);

const statusMsg = req.t('app-running');
const beConnected = await APIInstance.ping();

res.json({
status: statusMsg,
notes: req.t('health-notes'),
Expand Down
23 changes: 11 additions & 12 deletions src/routes/publish.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import { Blob } from 'buffer';

import { Request, Response, Router } from 'express';
import pino from 'pino';
import multer from 'multer';

import { logger } from '../utils/logger';
import { ViewErrDTO } from '../dtos/view-dto';
import { i18next } from '../middleware/translation';
import { API } from '../services/api';
import { StatsWalesApi } from '../services/stats-wales-api';

const t = i18next.t;
const storage = multer.memoryStorage();
const upload = multer({ storage });
const APIInstance = new API();

const logger = pino({
name: 'StatsWales-Alpha-App: Publish',
level: 'debug'
});

export const publish = Router();

Expand Down Expand Up @@ -65,6 +59,8 @@ publish.post('/name', upload.none(), (req: Request, res: Response) => {

publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => {
const lang = req.i18n.language;
const statsWalesApi = new StatsWalesApi(lang);

if (!req.body?.internal_name) {
logger.debug('Internal name was missing on request');
const err: ViewErrDTO = {
Expand All @@ -76,7 +72,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response
field: 'internal_name',
message: [
{
lang: req.i18n.language,
lang,
message: t('errors.name_missing')
}
],
Expand All @@ -91,8 +87,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response
res.render('publish/name', err);
return;
}

logger.debug(`Internal name: ${req.body.internal_name}`);
const internalName: string = req.body.internal_name;

if (!req.file) {
logger.debug('Attached file was missing on this request');
const err: ViewErrDTO = {
Expand All @@ -104,7 +102,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response
field: 'csv',
message: [
{
lang: req.i18n.language,
lang,
message: t('errors.upload.no-csv-data')
}
],
Expand All @@ -122,9 +120,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response

const fileData = new Blob([req.file?.buffer]);

const processedCSV = await APIInstance.uploadCSV(lang, fileData, internalName);
const processedCSV = await statsWalesApi.uploadCSV(fileData, internalName);

if (processedCSV.success) {
res.redirect(`/${req.i18n.language}/dataset/${processedCSV.dataset?.id}`);
res.redirect(`/${lang}/dataset/${processedCSV.dataset?.id}`);
} else {
res.status(400);
res.render('publish/upload', processedCSV);
Expand Down
20 changes: 12 additions & 8 deletions src/routes/view.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { Router, Request, Response } from 'express';
import { Router, Response } from 'express';

import { API } from '../services/api';
import { StatsWalesApi } from '../services/stats-wales-api';
import { FileList } from '../dtos/filelist';
import { ViewErrDTO } from '../dtos/view-dto';
import { i18next } from '../middleware/translation';
import { logger } from '../utils/logger';
import { AuthedRequest } from '../interfaces/authed-request';

const t = i18next.t;
const APIInstance = new API();
export const view = Router();

view.get('/', async (req: Request, res: Response) => {
const statsWalesApi = (req: AuthedRequest) => {
const lang = req.i18n.language;
const fileList: FileList = await APIInstance.getFileList(lang);
const token = req.jwt;
return new StatsWalesApi(lang, token);
};

view.get('/', async (req: AuthedRequest, res: Response) => {
const fileList: FileList = await statsWalesApi(req).getFileList();
logger.debug(`FileList from server = ${JSON.stringify(fileList)}`);
res.render('list', fileList);
});

view.get('/:file', async (req: Request, res: Response) => {
const lang = req.i18n.language;
view.get('/:file', async (req: AuthedRequest, res: Response) => {
const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1;
const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100;

Expand Down Expand Up @@ -49,7 +53,7 @@ view.get('/:file', async (req: Request, res: Response) => {
}

const file_id = req.params.file;
const file = await APIInstance.getFileData(lang, file_id, page_number, page_size);
const file = await statsWalesApi(req).getFileData(file_id, page_number, page_size);
if (!file.success) {
res.status((file as ViewErrDTO).status);
}
Expand Down
36 changes: 22 additions & 14 deletions src/services/api.ts → src/services/stats-wales-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ class HttpError extends Error {
}
}

export class API {
private readonly backendUrl: string | undefined;
export class StatsWalesApi {
private readonly backendUrl = process.env.BACKEND_URL || '';
private readonly authHeader: Record<string, string>;

constructor() {
this.backendUrl = process.env.BACKEND_URL;
constructor(
private lang: string,
private token?: string
) {
this.lang = lang;
this.authHeader = token ? { Authorization: `Bearer ${token}` } : {};
}

public async getFileList(lang: string) {
logger.debug(`Fetching file list from ${this.backendUrl}/${lang}/dataset`);
const filelist: FileList = await fetch(`${this.backendUrl}/${lang}/dataset`)
public async getFileList() {
logger.debug(`Fetching file list from ${this.backendUrl}/${this.lang}/dataset`);

const filelist: FileList = await fetch(`${this.backendUrl}/${this.lang}/dataset`, { headers: this.authHeader })
.then((response) => {
if (response.ok) {
return response.json();
Expand All @@ -46,9 +52,10 @@ export class API {
return filelist;
}

public async getFileData(lang: string, file_id: string, page_number: number, page_size: number) {
public async getFileData(file_id: string, page_number: number, page_size: number) {
const file = await fetch(
`${this.backendUrl}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}`
`${this.backendUrl}/${this.lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}`,
{ headers: this.authHeader }
)

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
.then((response) => {
if (response.ok) {
Expand All @@ -71,7 +78,7 @@ export class API {
field: 'file',
message: [
{
lang,
lang: this.lang,
message: 'errors.dataset_missing'
}
],
Expand All @@ -87,14 +94,15 @@ export class API {
return file;
}

public async uploadCSV(lang: string, file: Blob, filename: string) {
public async uploadCSV(file: Blob, filename: string) {
const formData = new FormData();
formData.append('csv', file, filename);
formData.append('internal_name', filename);

const processedCSV = await fetch(`${this.backendUrl}/${lang}/dataset/`, {
const processedCSV = await fetch(`${this.backendUrl}/${this.lang}/dataset/`, {
method: 'POST',
body: formData
body: formData,
headers: this.authHeader
})
.then((response) => {
if (response.ok) {
Expand All @@ -117,7 +125,7 @@ export class API {
field: 'csv',
message: [
{
lang,
lang: this.lang,
message: 'errors.upload.no-csv-data'
}
],
Expand Down
10 changes: 5 additions & 5 deletions test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@ jest.mock('../src/middleware/ensure-authenticated', () => ({
}));

const server = setupServer(
http.get('http://somehost.com:3001/en-GB/dataset', () => {
http.get('http://example.com:3001/en-GB/dataset', () => {
return HttpResponse.json({
filelist: [{ internal_name: 'test-data-1.csv', id: 'bdc40218-af89-424b-b86e-d21710bc92f1' }]
});
}),
http.get('http://somehost.com:3001/en-GB/dataset/missing-id/view', () => {
http.get('http://example.com:3001/en-GB/dataset/missing-id/view', () => {
return new HttpResponse(null, {
status: 404,
statusText: '{}'
});
}),
http.get('http://somehost.com:3001/en-GB/dataset/fa07be9d-3495-432d-8c1f-d0fc6daae359/view', () => {
http.get('http://example.com:3001/en-GB/dataset/fa07be9d-3495-432d-8c1f-d0fc6daae359/view', () => {
return HttpResponse.json({
success: true,
dataset: {
Expand Down Expand Up @@ -61,7 +61,7 @@ const server = setupServer(
]
});
}),
http.post('http://somehost.com:3001/en-GB/dataset/', async (req) => {
http.post('http://example.com:3001/en-GB/dataset/', async (req) => {
const data = await req.request.formData();
const internalName = data.get('internal_name') as string;
if (internalName === 'test-data-3.csv fail test') {
Expand Down Expand Up @@ -100,7 +100,7 @@ const server = setupServer(
}
});
}),
http.get('http://somehost.com:3001/healthcheck', () => {
http.get('http://example.com:3001/healthcheck', () => {
return HttpResponse.json({
status: 'App is running',
notes: 'Expand endpoint to check for database connection and other services.'
Expand Down

0 comments on commit a6edd2a

Please sign in to comment.