diff --git a/.gitignore b/.gitignore index a508a3f..e067215 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ public/* # Access access/gsheets.json -access/token.json +access/jwt_secret.json # Misc .DS_Store diff --git a/package.json b/package.json index ef6585a..aca4148 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "hpp": "^0.2.3", "html-minifier": "^4.0.0", "https": "^1.0.0", + "jsonwebtoken": "^8.5.1", "lodash": "^4.17.21", "morgan": "^1.10.0", "normalize.css": "^8.0.1", @@ -125,11 +126,12 @@ "@types/compression": "^1.7.1", "@types/compression-webpack-plugin": "^6.0.6", "@types/css-minimizer-webpack-plugin": "^3.0.2", - "@types/express": "^4.17.13", + "@types/express": "4.17.1", "@types/google-spreadsheet": "^3.1.5", "@types/hpp": "^0.2.1", "@types/html-minifier": "^4.0.1", "@types/jest": "^26.0.24", + "@types/jsonwebtoken": "^8.5.6", "@types/loadable__component": "^5.13.4", "@types/loadable__server": "^5.12.6", "@types/loadable__webpack-plugin": "^5.7.3", diff --git a/src/config/default.ts b/src/config/default.ts index 02d551c..7f88912 100755 --- a/src/config/default.ts +++ b/src/config/default.ts @@ -15,4 +15,5 @@ export default { }, ], }, + JWT_SECRET: "RblQqA6uF#msq2312bebf2FLFn4XzWQ6dttXSJwBX#?gL2JWf!", } diff --git a/src/server/index.ts b/src/server/index.ts index 3865ee0..581cf4c 100755 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,5 @@ import path from "path" -import express from "express" +import express, { RequestHandler } from "express" import logger from "morgan" import compression from "compression" import helmet from "helmet" @@ -15,6 +15,7 @@ import devServer from "./devServer" import ssr from "./ssr" import certbotRouter from "../routes/certbot" +import { secure } from "./secure" import { jeuJavListGet } from "./gsheets/jeuJav" import { envieListGet, envieAdd } from "./gsheets/envies" import { membreGet, membreSet } from "./gsheets/membres" @@ -43,21 +44,23 @@ app.use(express.static(path.resolve(process.cwd(), "public"))) // Enable dev-server in development if (__DEV__) devServer(app) -/** - * APIs - */ - -// Google Sheets API app.use(express.json()) -app.get("/JeuJavListGet", jeuJavListGet) -app.get("/EnvieListGet", envieListGet) -app.get("/MembreGet", membreGet) -app.post("/MembreSet", membreSet) -app.post("/EnvieAdd", envieAdd) // Sign in & up API app.post("/api/user/login", loginHandler) +/** + * APIs + */ +// Google Sheets API +app.get("/JeuJavListGet", jeuJavListGet) +app.get("/EnvieListGet", envieListGet) +app.post("/EnvieAdd", envieAdd) + +// Secured APIs +app.get("/MembreGet", secure as RequestHandler, membreGet) +app.post("/MembreSet", secure as RequestHandler, membreSet) + // Use React server-side rendering middleware app.get("*", ssr) diff --git a/src/server/secure.ts b/src/server/secure.ts new file mode 100644 index 0000000..a0855b4 --- /dev/null +++ b/src/server/secure.ts @@ -0,0 +1,58 @@ +import { NextFunction, Request, Response } from "express" +import path from "path" +import { promises as fs } from "fs" +import { verify, sign } from "jsonwebtoken" +import { canonicalEmail } from "../utils/standardization" + +import config from "../config" + +type AuthorizedRequest = Request & { headers: { authorization: string } } + +let cachedSecret: string +getSecret() // Necessary until we can make async express middleware + +export function secure(request: AuthorizedRequest, response: Response, next: NextFunction): void { + if (!cachedSecret) { + response.status(408).json({ + error: "Server still loading", + }) + return + } + + const rawToken = request.headers.authorization + const token = rawToken && rawToken.split(/\s/)[1] + + verify(token, cachedSecret, (tokenError: any, decoded: any) => { + if (tokenError) { + response.status(403).json({ + error: "Invalid token, please Log in first, criterion auth", + }) + return + } + decoded.user.replace(/@gmailcom$/, "@gmail.com") + response.locals.jwt = decoded + next() + }) +} + +async function getSecret() { + if (!cachedSecret) { + const SECRET_PATH = path.resolve(process.cwd(), "access/jwt_secret.json") + + try { + const secretContent = await fs.readFile(SECRET_PATH) + cachedSecret = secretContent && JSON.parse(secretContent.toString()).secret + } catch (e: any) { + cachedSecret = config.JWT_SECRET + } + } + + return cachedSecret +} + +export async function getJwt(email: string): Promise { + const jwt = sign({ user: canonicalEmail(email), permissions: [] }, await getSecret(), { + expiresIn: "7d", + }) + return jwt +} diff --git a/src/server/userManagement/login.ts b/src/server/userManagement/login.ts index 611430b..3f4eda2 100644 --- a/src/server/userManagement/login.ts +++ b/src/server/userManagement/login.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express" import bcrypt from "bcrypt" import { Membre, MemberLogin, emailRegexp, passwordMinLength } from "../../services/membres" import getAccessors from "../gsheets/accessors" +import { getJwt } from "../secure" const { listGet } = getAccessors("Membres", new Membre()) @@ -50,9 +51,12 @@ export async function login(rawEmail: string, rawPassword: string): Promise(elementName: string): (id: number) => Promise<{ return async (id: number): Promise => { try { const { data } = await axios.get(`${config.API_URL}/${elementName}Get`, { + ...axiosConfig, params: { id }, }) return { data } @@ -34,7 +36,7 @@ export function listGet(elementName: string): () => Promise<{ } return async (): Promise => { try { - const { data } = await axios.get(`${config.API_URL}/${elementName}ListGet`) + const { data } = await axios.get(`${config.API_URL}/${elementName}ListGet`, axiosConfig) return { data } } catch (error) { return { error: error as Error } @@ -57,7 +59,8 @@ export function add(elementName: string): (membre: Element) => Promise< } return async (membre: Element): Promise => { try { - const { data } = await axios.post(`${config.API_URL}/${elementName}Set`, membre) + const { data } = await axios.post( + `${config.API_URL}/${elementName}Set`, + membre, + axiosConfig + ) return { data } } catch (error) { return { error: error as Error } diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..95b19a5 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,22 @@ +import { AxiosRequestConfig } from "axios" + +const storage: any = localStorage + +const jwt: string | null = storage?.getItem("id_token") +if (jwt) { + setJWT(jwt) +} + +export const axiosConfig: AxiosRequestConfig = { + headers: {}, +} + +export function setJWT(token: string): void { + axiosConfig.headers.Authorization = `Bearer ${token}` + storage?.setItem("id_token", token) +} + +export function unsetJWT(): void { + delete axiosConfig.headers.Authorization + storage?.removeItem("id_token") +} diff --git a/src/services/membres.ts b/src/services/membres.ts index 1887505..d15a21c 100644 --- a/src/services/membres.ts +++ b/src/services/membres.ts @@ -36,6 +36,7 @@ export interface MemberLogin { membre?: { prenom: string } + jwt?: string error?: string } diff --git a/src/utils/standardization.ts b/src/utils/standardization.ts new file mode 100644 index 0000000..e06c55b --- /dev/null +++ b/src/utils/standardization.ts @@ -0,0 +1,18 @@ +export function canonicalEmail(email: string): string { + email = email.replace(/^\s+|\s+$/g, "") + if (/@gmail.com$/.test(email)) { + let domain = email.replace(/^.*@/, "") + domain = domain.replace(/^googlemail%.com$/, "gmail.com") + email = email + .replace(/\./g, "") + .replace(/^[^@]+/, (match) => match.toLowerCase()) + .replace(/@.*$/, `@${domain}`) + } + return email.toLowerCase() +} + +export function validEmail(email: string): boolean { + return /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test( + email + ) +} diff --git a/webpack/server.config.ts b/webpack/server.config.ts index 6007bef..06c04ab 100644 --- a/webpack/server.config.ts +++ b/webpack/server.config.ts @@ -29,6 +29,9 @@ const config: Configuration = { banner: 'require("source-map-support").install();', raw: true, }), + new webpack.DefinePlugin({ + localStorage: { getItem: () => null, setItem: () => null, removeItem: () => null }, + }), ], } diff --git a/yarn.lock b/yarn.lock index b0b723e..79875c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1581,6 +1581,15 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== +"@types/express-serve-static-core@*": + version "4.17.26" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.26.tgz#5d9a8eeecb9d5f9d7fc1d85f541512a84638ae88" + integrity sha512-zeu3tpouA043RHxW0gzRxwCHchMgftE8GArRsvYT0ByDMbn19olQHx5jLue0LxWY6iYtXb7rXmuVtSkhy9YZvQ== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/express-serve-static-core@^4.17.18": version "4.17.25" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.25.tgz#e42f7046adc65ece2eb6059b77aecfbe9e9f82e0" @@ -1590,7 +1599,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@*", "@types/express@^4.17.13": +"@types/express@*": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== @@ -1600,6 +1609,15 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/express@4.17.1": + version "4.17.1" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.1.tgz#4cf7849ae3b47125a567dfee18bfca4254b88c5c" + integrity sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "*" + "@types/serve-static" "*" + "@types/glob@^7.1.1": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" @@ -1694,6 +1712,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.5.6": + version "8.5.6" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.6.tgz#1913e5a61e70a192c5a444623da4901a7b1a9d42" + integrity sha512-+P3O/xC7nzVizIi5VbF34YtqSonFsdnbXBnWUCYRiKOi1f9gA4sEFvXkrGr/QVV23IbMYvcoerI7nnhDUiWXRQ== + dependencies: + "@types/node" "*" + "@types/loadable__component@^5.13.4": version "5.13.4" resolved "https://registry.yarnpkg.com/@types/loadable__component/-/loadable__component-5.13.4.tgz#a4646b2406b1283efac1a9d9485824a905b33d4a" @@ -6873,6 +6898,22 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -6896,6 +6937,15 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jwa@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" @@ -6905,6 +6955,14 @@ jwa@^2.0.0: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + jws@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" @@ -7103,16 +7161,41 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + lodash.isequalwith@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz#266726ddd528f854f21f4ea98a065606e0fbc6b0" integrity sha1-Jmcm3dUo+FTyH06pigZWBuD7xrA= +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -7123,6 +7206,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"