diff --git a/.env.example b/.env.example index f7a0c16..bb5a696 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,23 @@ BACKEND_ADDRESS=127.0.0.1 BACKEND_PORT=3000 # Application database URL (required) +# Postgres: DB_URL=postgres://user:password@host:port/dbname +# SQLite: +# DB_URL=sqlite:///path/to/app.db + +# The server key is used to sign JWT tokens (required) +# It should be a random string with at least 32 characters. +# You can generate a random string with the following command: +# openssl rand -base64 32 +SERVER_KEY=your_server_key + +# Admin password (required) +# The admin password is used to authenticate the admin user. +ADMIN_PASSWORD=your_admin_password + +# Admin user email (required) +ADMIN_EMAIL=admin@example.com # If the location data is stored on another db than the one used by the backend, # you can specify a different database URL here. @@ -21,8 +37,12 @@ DB_URL=postgres://user:password@host:port/dbname # Location database dialect (default: inferred from the URL) # DB_LOCATION_DIALECT=postgres +# Prefix for the application tables (default: empty). +# Note that this does not apply to DB_LOCATION_TABLE if it is set. +DB_TABLE_PREFIX= + # Name of the table that contains the location points (required) -DB_LOCATION_TABLE=gpsdata +DB_LOCATION_TABLE=location_history ## Database mappings # The name of the column that contains the primary key of each location point diff --git a/.gitignore b/.gitignore index b1f722a..7410ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ coverage *.sw? *.vim *.tsbuildinfo + +# Database files +*.db +*.sqlite +*.sqlite3 diff --git a/package-lock.json b/package-lock.json index c593992..e8e29ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,19 +7,23 @@ "": { "name": "server", "version": "1.0.0", - "license": "ISC", + "license": "GPL-3.0", "dependencies": { + "bcryptjs": "^3.0.2", + "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "passport": "^0.7.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.3", "sequelize": "^6.37.5", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "uuid": "^11.1.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22.13.4", "nodemon": "^3.1.9", "typescript": "^5.7.3" @@ -141,6 +145,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -370,6 +385,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -475,6 +499,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -760,6 +790,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1505,12 +1544,103 @@ "license": "MIT", "optional": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "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": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2017,32 +2147,6 @@ "node": ">= 0.8" } }, - "node_modules/passport": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", - "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "license": "MIT", - "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2059,11 +2163,6 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" - }, "node_modules/pg": { "version": "8.13.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", @@ -2567,6 +2666,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -3114,12 +3222,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validator": { diff --git a/package.json b/package.json index 9793985..de19dc0 100644 --- a/package.json +++ b/package.json @@ -21,20 +21,24 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "GPL-3.0", "type": "commonjs", "dependencies": { + "bcryptjs": "^3.0.2", + "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "passport": "^0.7.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.3", "sequelize": "^6.37.5", - "sqlite3": "^5.1.7" + "sqlite3": "^5.1.7", + "uuid": "^11.1.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22.13.4", "nodemon": "^3.1.9", "typescript": "^5.7.3" diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..0c7ecd6 --- /dev/null +++ b/src/App.ts @@ -0,0 +1,56 @@ +import cors from 'cors'; +import express from 'express'; +import bodyParser from 'body-parser'; + +import { useGlobals } from './globals'; +import Routes from './routes'; + +class App { + private readonly app: express.Express; + private readonly address: string; + private readonly port: number; + + private constructor({ + app, + address, + port, + routes, + }: any) { + useGlobals(); + $db.sync().then(() => { + $repos.userRoles.init(); + $repos.users.syncAdminUser(); + }) + + this.app = app; + this.address = address; + this.port = port; + + app.use(cors()); + app.use(bodyParser.json()); + app.use(express.static('frontend/dist')); + routes.register(app) + } + + public static fromEnv(): App { + const address = process.env.BACKEND_ADDRESS || '127.0.0.1'; + const port = new Number(process.env.BACKEND_PORT || 3000).valueOf(); + const app = express(); + const routes = new Routes(); + + return new App({ + app, + address, + port, + routes, + }); + } + + public listen(): void { + this.app.listen(this.port, this.address, () => { + console.log(`Server is running on port ${this.address}:${this.port}`); + }); + } +} + +export default App; diff --git a/src/Secrets.ts b/src/Secrets.ts new file mode 100644 index 0000000..384c2a3 --- /dev/null +++ b/src/Secrets.ts @@ -0,0 +1,40 @@ +class Secrets { + public readonly serverKey: string; + public readonly adminPassword: string; + public readonly adminEmail: string; + + private constructor({ + serverKey, + adminPassword, + adminEmail, + }: any) { + this.serverKey = serverKey; + this.adminPassword = adminPassword; + this.adminEmail = adminEmail; + } + + public static fromEnv(): Secrets { + if (!process.env.SERVER_KEY?.length) { + throw new Error( + 'SERVER_KEY not found in environment.\n' + + 'Generate one with `openssl rand -base64 32` and add it to your environment.' + ); + } + + if (!process.env.ADMIN_PASSWORD?.length) { + throw new Error('ADMIN_PASSWORD not found in environment.'); + } + + if (!process.env.ADMIN_EMAIL?.length) { + throw new Error('ADMIN_EMAIL not found in environment.'); + } + + return new Secrets({ + serverKey: process.env.SERVER_KEY, + adminPassword: process.env.ADMIN_PASSWORD, + adminEmail: process.env.ADMIN_EMAIL, + }); + } +} + +export default Secrets; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..0f820c1 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,74 @@ +import { Request } from 'express'; + +import { Forbidden, Unauthorized } from './errors'; +import { Optional } from '~/types'; +import { RoleName, User, UserSession } from '~/models'; +import Route from './routes/Route'; + +class AuthInfo { + public user: User; + public session: Optional<UserSession>; + + constructor(user: User, session: Optional<UserSession> = null) { + this.user = user; + this.session = session; + } +} + +function authenticate(roles: RoleName[] = []) { + return function (route: any, method: string) { + const routeClass = (<typeof Route> route.constructor); + routeClass.preRequestHandlers[method] = async (req: Request): Promise<AuthInfo> => { + let user: Optional<User>; + let session: Optional<UserSession>; + + // Check the `session` cookie or the `Authorization` header for the session token + let token = req.cookies?.session; + if (!token?.length) { + const authHeader = req.headers?.authorization; + if (authHeader?.startsWith('Bearer ')) { + token = authHeader.slice(7); + } + } + + // Check if the token is valid + if (token?.length) { + session = await $repos.userSessions.byToken(token); + if (session) { + user = await $repos.users.get(session.userId); + } + } + + if (!(session && user)) { + // Check the `username` and `password` query or body parameters + const username = req.body?.username || req.query?.username; + const password = req.body?.password || req.query?.password; + + if (username?.length && password?.length) { + user = await $repos.users.find(username); + if (!(user && user.checkPassword(password))) { + user = null; + } + } + } + + if (!user) { + throw new Unauthorized('Invalid credentials'); + } + + if (roles.length) { + // Check if the user has the required roles + const userRoles = new Set((await user.roles()).map((role) => role.name)); + const missingPermissions = roles.filter((role) => !userRoles.has(role)); + + if (missingPermissions.length) { + throw new Forbidden('Missing required roles: [' + missingPermissions.join(', ') + ']'); + } + } + + return new AuthInfo(user, session); + } + } +} + +export { AuthInfo, authenticate }; diff --git a/src/db/Db.ts b/src/db/Db.ts index 103b370..ada801e 100644 --- a/src/db/Db.ts +++ b/src/db/Db.ts @@ -1,6 +1,10 @@ import { Sequelize, Dialect } from 'sequelize'; import GPSData from './types/GPSData'; +import Role from './types/Role'; +import User from './types/User'; +import UserRole from './types/UserRole'; +import UserSession from './types/UserSession'; class Db { private readonly url: string; @@ -9,6 +13,7 @@ class Db { public readonly locationTableColumns: Record<string, string>; private readonly dialect: Dialect; private readonly locationDialect: Dialect; + private readonly tablePrefix: string; private readonly appDb: Sequelize; private readonly locationDb: Sequelize; @@ -18,18 +23,20 @@ class Db { opts: { url: string, locationUrl: string, - locationTable: string, + locationTable?: string | null, locationTableColumns: Record<string, string>, dialect: Dialect, locationDialect: Dialect | null, + tablePrefix?: string | null, } ) { this.url = opts.url; this.locationUrl = opts.locationUrl; - this.locationTable = opts.locationTable; this.locationTableColumns = opts.locationTableColumns this.dialect = opts.dialect as Dialect; this.locationDialect = (opts.locationDialect || this.dialect) as Dialect; + this.tablePrefix = opts.tablePrefix || ''; + this.locationTable = opts.locationTable || this.tableName('location_history'); this.appDb = new Sequelize(this.url, { dialect: this.dialect, @@ -53,6 +60,7 @@ class Db { opts.locationTable = process.env.DB_LOCATION_TABLE; opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0]; opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0]; + opts.tablePrefix = process.env.DB_TABLE_PREFIX; if (!opts.url?.length) { console.error('No DB_URL provided'); @@ -81,7 +89,7 @@ class Db { 'country', 'postal_code' ].reduce((acc: any, name: string) => { - acc[name] = process.env[this.prefixed(name)]; + acc[name] = process.env[this.prefixedEnv(name)]; if (!acc[name]?.length && requiredColumns[name]) { // Default to the name of the required field acc[name] = name; @@ -93,10 +101,66 @@ class Db { return new Db(opts); } - private static prefixed(name: string): string { + private static prefixedEnv(name: string): string { return `${Db.envColumnPrefix}${name.toUpperCase()}`; } + public tableName(table: string): string { + return `${this.tablePrefix}${table}`; + } + + public async sync() { + console.log('Syncing databases'); + + const gpsData = this.GPSData(); + const role = this.Role(); + const user = this.User(); + const userRole = this.UserRole(); + const userSession = this.UserSession(); + this.initConstraints(); + + await gpsData.sync(); + await role.sync(); + await user.sync(); + await userRole.sync(); + await userSession.sync(); + + await this.appDb.sync(); + console.log('Database sync completed'); + } + + private initConstraints() { + this.appDb.models.UserSession.belongsTo(this.appDb.models.User, { + foreignKey: 'userId', + targetKey: 'id', + as: 'user', + onDelete: 'CASCADE' + }); + + this.appDb.models.User.hasMany(this.appDb.models.UserSession, { + foreignKey: 'userId', + sourceKey: 'id', + as: 'sessions' + }); + + this.appDb.models.User.belongsToMany(this.appDb.models.Role, { + through: this.appDb.models.UserRole, + foreignKey: 'userId', + otherKey: 'roleId', + as: 'roles' + }); + + this.appDb.models.Role.belongsToMany(this.appDb.models.User, { + through: this.appDb.models.UserRole, + foreignKey: 'roleId', + otherKey: 'userId', + as: 'users' + }); + + this.appDb.models.UserSession.sync(); + this.appDb.sync(); + } + /** * Tables */ @@ -104,9 +168,50 @@ class Db { public GPSData() { return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), { tableName: this.locationTable, - timestamps: false + timestamps: false, }); } + + public Role() { + return this.appDb.define('Role', Role(), { + tableName: this.tableName('roles'), + timestamps: false, + }); + } + + public User() { + return this.appDb.define('User', User(), { + indexes: [ + { + unique: true, + fields: ['username', 'email'], + }, + ], + tableName: this.tableName('users'), + timestamps: false, + }); + } + + public UserRole() { + return this.appDb.define('UserRole', UserRole(), { + tableName: this.tableName('user_roles'), + timestamps: false, + }); + } + + public UserSession() { + const ret = this.appDb.define('UserSession', UserSession(), { + indexes: [ + { + fields: ['userId'], + }, + ], + tableName: this.tableName('user_sessions'), + timestamps: false, + }); + + return ret; + } } export default Db; diff --git a/src/db/types/Role.ts b/src/db/types/Role.ts new file mode 100644 index 0000000..5a1aa41 --- /dev/null +++ b/src/db/types/Role.ts @@ -0,0 +1,24 @@ +import { DataTypes, } from 'sequelize'; + +function Role(): Record<string, any> { + return { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + allowNull: false, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + }, + }; +} + +export default Role; diff --git a/src/db/types/User.ts b/src/db/types/User.ts new file mode 100644 index 0000000..4650164 --- /dev/null +++ b/src/db/types/User.ts @@ -0,0 +1,46 @@ +import { DataTypes, } from 'sequelize'; + +function User(): Record<string, any> { + return { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + password: { + type: DataTypes.STRING, + allowNull: false + }, + firstName: { + type: DataTypes.STRING, + allowNull: false + }, + lastName: { + type: DataTypes.STRING, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + onUpdate: () => new Date(), + }, + }; +} + +export default User; diff --git a/src/db/types/UserRole.ts b/src/db/types/UserRole.ts new file mode 100644 index 0000000..246f22d --- /dev/null +++ b/src/db/types/UserRole.ts @@ -0,0 +1,32 @@ +import { DataTypes, } from 'sequelize'; + +function UserRole(): Record<string, any> { + return { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + roleId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + onUpdate: () => new Date(), + }, + }; +} + +export default UserRole; diff --git a/src/db/types/UserSession.ts b/src/db/types/UserSession.ts new file mode 100644 index 0000000..2e9ec0d --- /dev/null +++ b/src/db/types/UserSession.ts @@ -0,0 +1,34 @@ +import { DataTypes } from 'sequelize'; +import { v4 as uuidv4 } from 'uuid'; + +function UserSession(): Record<string, any> { + return { + id: { + type: DataTypes.UUID, + primaryKey: true, + allowNull: false, + defaultValue: () => uuidv4(), + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: () => new Date(), + onUpdate: () => new Date(), + }, + } +} + +export default UserSession; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..80340c9 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,20 @@ +abstract class ApplicationError extends Error { + constructor(public message: string) { + super(message); + } +} + +class BadRequest extends ApplicationError { } +class ValidationError extends BadRequest { } +class Unauthorized extends BadRequest { } +class Forbidden extends BadRequest { } +class NotFound extends BadRequest { } + +export { + ApplicationError, + BadRequest, + Forbidden, + NotFound, + Unauthorized, + ValidationError, +}; diff --git a/src/globals.ts b/src/globals.ts index 33017da..2ddb3eb 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -1,6 +1,7 @@ import dotenv from 'dotenv'; import { Db } from './db'; +import Secrets from './Secrets'; import Repositories from './repos'; dotenv.config(); @@ -8,9 +9,11 @@ dotenv.config(); declare global { var $db: Db; var $repos: Repositories; + var $secrets: Secrets; } export function useGlobals() { + globalThis.$secrets = Secrets.fromEnv(); globalThis.$db = Db.fromEnv(); - globalThis.$repos = new Repositories(globalThis.$db); + globalThis.$repos = new Repositories(); } diff --git a/src/helpers/random.ts b/src/helpers/random.ts new file mode 100644 index 0000000..a69f554 --- /dev/null +++ b/src/helpers/random.ts @@ -0,0 +1,13 @@ +function randomString(length: number) { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +} + +export { randomString }; diff --git a/src/helpers/security.ts b/src/helpers/security.ts new file mode 100644 index 0000000..7fa7377 --- /dev/null +++ b/src/helpers/security.ts @@ -0,0 +1,11 @@ +function maskPassword(obj: Record<string, any>): Record<string, any> { + if (obj.password) { + delete obj.password; + } + + return obj; +} + +export { + maskPassword, +}; diff --git a/src/main.ts b/src/main.ts index 5839b0b..4847d9b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,20 +1,4 @@ -import cors from 'cors'; -import express from 'express'; +import App from './App'; -import { useGlobals } from './globals'; -useGlobals(); - -import Routes from './routes'; - -const app = express(); -const address = process.env.BACKEND_ADDRESS || '127.0.0.1'; -const port = process.env.BACKEND_PORT || 3000; -const routes = new Routes(); - -app.use(cors()); -app.use(express.static('frontend/dist')); -routes.register(app) - -app.listen(new Number(port).valueOf(), address, () => { - console.log(`Server is running on port ${address}:${port}`); -}); +const app = App.fromEnv(); +app.listen(); diff --git a/src/models/Role.ts b/src/models/Role.ts new file mode 100644 index 0000000..268d08b --- /dev/null +++ b/src/models/Role.ts @@ -0,0 +1,57 @@ +import { Op } from 'sequelize'; +import { Optional } from '~/types'; +import RoleName from './RoleName'; + +class Role { + public id: number; + public name: RoleName; + public createdAt: Optional<Date>; + + constructor({ + id, + name, + createdAt = null, + }: any) { + this.id = id; + this.name = name; + this.createdAt = createdAt; + } + + public async get(role: number | string): Promise<Optional<Role>> { + const dbRole = await $db.Role().findOne({ + where: { + [Op.or]: [ + { id: role }, + { name: role }, + ], + }, + }); + + if (!dbRole) { + return null; + } + + return new Role(dbRole.dataValues); + } + + public async save(): Promise<void> { + const userRole = await $db.Role().findOne({ + where: { + name: this.name, + }, + }); + + if (!userRole) { + await $db.Role().create({ + name: this.name, + createdAt: new Date(), + }); + } else { + await userRole.update({ + name: this.name, + }); + } + } +} + +export default Role; diff --git a/src/models/RoleName.ts b/src/models/RoleName.ts new file mode 100644 index 0000000..e038159 --- /dev/null +++ b/src/models/RoleName.ts @@ -0,0 +1,6 @@ +enum RoleName { + Admin = 'admin', + User = 'user', +} + +export default RoleName; diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..d9b5d06 --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,113 @@ +import bcrypt from 'bcryptjs'; +import { Op } from 'sequelize'; + +import { Optional } from '~/types'; +import Role from './Role'; +import RoleName from './RoleName'; + +class User { + public id: number; + public username: string; + public password: string; + public email: string; + public firstName: Optional<string>; + public lastName: Optional<string>; + public createdAt: Optional<Date>; + public updatedAt: Optional<Date>; + + constructor({ + id, + username, + email, + password, + firstName = null, + lastName = null, + createdAt = null, + updatedAt = null, + }: User) { + this.id = id; + this.username = username; + this.email = email; + this.password = password; + this.firstName = firstName; + this.lastName = lastName; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static hashPassword(password: string): string { + return bcrypt.hashSync(password, bcrypt.genSaltSync(10)); + } + + public checkPassword(password: string): boolean { + return bcrypt.compareSync(password, this.password); + } + + public async roles(): Promise<Role[]> { + return ( + await $db.UserRole().findAll({ + where: { + userId: this.id, + }, + }) + ).map((role) => new Role(role.dataValues)); + } + + public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> { + const rolesToFetch = roles.map((role) => { + if (role instanceof Role) { + return role.id || role.name; + } + + return role; + }); + + const roleIds = new Set( + ( + await $db.Role().findAll({ + where: { + [Op.or]: { + id: rolesToFetch, + name: rolesToFetch, + }, + }, + }) + ).map((role) => (role as any).id) + ); + + const userRoles = await $db.UserRole().findAll({ + where: { + userId: this.id, + }, + }); + + const userRoleIds = new Set(userRoles.map((role) => (role as any).roleId)); + const toAdd = [...roleIds].filter((roleId) => !userRoleIds.has(roleId)); + const toRemove = userRoles.filter((role) => !roleIds.has((role as any).roleId)).map((role) => (role as any).roleId); + + await $db.UserRole().bulkCreate( + toAdd.map((roleId) => ({ + userId: this.id, + roleId: roleId, + })), + ); + + await $db.UserRole().destroy({ + where: { + userId: this.id, + roleId: toRemove, + }, + }); + } + + public async save(): Promise<void> { + this.password = User.hashPassword(this.password); + await $db.User().update(this, { + where: { + id: this.id, + }, + }); + } +} + +export default User; diff --git a/src/models/UserSession.ts b/src/models/UserSession.ts new file mode 100644 index 0000000..912e12d --- /dev/null +++ b/src/models/UserSession.ts @@ -0,0 +1,48 @@ +import jwt from 'jsonwebtoken'; + +import { Optional } from '~/types'; + +class UserSession { + public id: string; + public userId: number; + public expiresAt: Optional<Date>; + public createdAt: Optional<Date>; + public updatedAt: Optional<Date>; + + constructor({ + id, + userId, + expiresAt = null, + createdAt = null, + updatedAt = null, + }: any) { + this.id = id; + this.userId = userId; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + + ['expiresAt', 'createdAt', 'updatedAt'].forEach((key) => { + if ((this as any)[key] && !((this as any)[key] instanceof Date)) { + (this as any)[key] = new Date((this as any)[key]); + } + }); + } + + public getToken() { + return jwt.sign({ + sessionId: this.id, + userId: this.userId, + }, $secrets.serverKey); + } + + public destroy() { + return $db.UserSession().destroy({ + where: { + id: this.id, + }, + }); + } +} + +export default UserSession; diff --git a/src/models/index.ts b/src/models/index.ts index 19bc5f1..363374a 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,13 @@ import GPSPoint from "./GPSPoint"; +import Role from "./Role"; +import RoleName from "./RoleName"; +import User from "./User"; +import UserSession from "./UserSession"; export { GPSPoint, + Role, + RoleName, + User, + UserSession, }; diff --git a/src/repos/LocationRepository.ts b/src/repos/Location.ts similarity index 75% rename from src/repos/LocationRepository.ts rename to src/repos/Location.ts index a07a951..9decd44 100644 --- a/src/repos/LocationRepository.ts +++ b/src/repos/Location.ts @@ -1,19 +1,12 @@ -import { Db } from '~/db'; import { GPSPoint } from '../models'; import { LocationRequest } from '../requests'; -class LocationRepository { - private db: Db; - - constructor(db: Db) { - this.db = db; - } - +class Location { public async getHistory(query: LocationRequest): Promise<GPSPoint[]> { let apiResponse: any[] = []; try { - apiResponse = await this.db.GPSData().findAll(query.toMap(this.db)); + apiResponse = await $db.GPSData().findAll(query.toMap($db)); } catch (error) { throw new Error(`Error fetching data: ${error}`); } @@ -21,7 +14,7 @@ class LocationRepository { try { return apiResponse.map((p) => { const data = p.dataValues; - const mappings: any = this.db.locationTableColumns; + const mappings: any = $db.locationTableColumns; return new GPSPoint({ id: data[mappings.id], @@ -41,4 +34,4 @@ class LocationRepository { } } -export default LocationRepository; +export default Location; diff --git a/src/repos/UserRoles.ts b/src/repos/UserRoles.ts new file mode 100644 index 0000000..03176c6 --- /dev/null +++ b/src/repos/UserRoles.ts @@ -0,0 +1,13 @@ +import { Role, RoleName } from '../models'; + +class UserRoles { + private static userRoleNames: string[] = Object.values(RoleName); + + public async init(): Promise<void> { + UserRoles.userRoleNames.forEach(async (name) => { + new Role({ name }).save(); + }); + } +} + +export default UserRoles; diff --git a/src/repos/UserSessions.ts b/src/repos/UserSessions.ts new file mode 100644 index 0000000..42c11d4 --- /dev/null +++ b/src/repos/UserSessions.ts @@ -0,0 +1,58 @@ +import jwt from 'jsonwebtoken'; + +import { Optional } from '~/types'; +import { Unauthorized } from '../errors'; +import { UserSession } from '../models'; + +class UserSessions { + public async find(sessionId: string): Promise<Optional<UserSession>> { + const dbSession = await $db.UserSession().findByPk(sessionId) + if (!dbSession) { + return null; + } + + const session = new UserSession(dbSession.dataValues); + if (session.expiresAt && session.expiresAt < new Date()) { + await session.destroy(); + return null; + } + + return session; + } + + public async create(userId: number, expiresAt: Optional<Date> = null): Promise<UserSession> { + const session = await $db.UserSession().create({ + userId, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }); + + return new UserSession(session.dataValues); + } + + public async byToken(token: string): Promise<Optional<UserSession>> { + let payload: Record<string, any> + + try { + payload = jwt.verify(token, $secrets.serverKey) as { + sessionId: string, userId: number + }; + } catch (error) { + throw new Unauthorized('Invalid token'); + } + + const session = await this.find(payload.sessionId); + let expiresAt = session?.expiresAt; + + if ( + !session || + session.userId !== payload.userId || + (expiresAt && expiresAt < new Date()) + ) { + throw new Unauthorized('Invalid token'); + } + + return session; + } +} + +export default UserSessions; diff --git a/src/repos/Users.ts b/src/repos/Users.ts new file mode 100644 index 0000000..7053cbf --- /dev/null +++ b/src/repos/Users.ts @@ -0,0 +1,139 @@ +import { Op } from 'sequelize'; + +import { Optional } from '~/types'; +import { RoleName } from '../models'; +import { User } from '../models'; +import { ValidationError } from '../errors'; + +class Users { + public async syncAdminUser(): Promise<User> { + const adminPassword = $secrets.adminPassword; + let adminUser = await this.find('admin'); + const adminEmail = $secrets.adminEmail; + + if (adminUser) { + let changed = false; + + if (!adminUser.checkPassword(adminPassword)) { + adminUser.password = adminPassword; + changed = true; + } + + if (adminUser.email !== adminEmail) { + adminUser.email = adminEmail; + changed = true; + } + + if (!changed) { + return adminUser; + } + + await adminUser.save(); + } else { + console.log('Creating admin user'); + adminUser = await this.create({ + username: 'admin', + email: adminEmail, + password: adminPassword, + firstName: 'Admin', + lastName: 'User', + }); + + console.log('Admin user created'); + } + + await adminUser.setRoles([RoleName.Admin]); + return adminUser; + } + + public async get(userId: number): Promise<Optional<User>> { + const dbUser = await $db.User().findByPk(userId); + + if (!dbUser) { + return null; + } + + return new User(dbUser.dataValues); + } + + public async find(username: string): Promise<Optional<User>> { + const dbUser = await $db.User().findOne({ + where: { + [Op.or]: { + username, + email: username, + }, + }, + }); + + if (!dbUser) { + return null; + } + + return new User(dbUser.dataValues); + } + + public async set(username: string, { + password = null, + email = null, + firstName = null, + lastName = null, + }: any): Promise<void> { + const dbUser = await this.find(username); + let changed = false; + + if (!dbUser) { + console.log(`User ${username} not found`); + return; + } + + if (password?.length) { + console.log(`Updating password for ${username}`); + dbUser.password = password; + changed = true; + } + + if (email?.length) { + if (!email.includes('@')) { + throw new ValidationError('Invalid email'); + } + + dbUser.email = email; + changed = true; + } + + if (firstName?.length) { + dbUser.firstName = firstName; + changed = true; + } + + if (lastName?.length) { + dbUser.lastName = lastName; + changed = true; + } + + if (changed) { + await dbUser.save(); + } + } + + public async create({ + username, + email, + password, + firstName = null, + lastName = null, + }: any): Promise<User> { + const dbUser = await $db.User().create({ + username, + email, + password: User.hashPassword(password), + firstName, + lastName, + }); + + return new User(dbUser.dataValues); + } +} + +export default Users; diff --git a/src/repos/index.ts b/src/repos/index.ts index 6477ff7..7dced50 100644 --- a/src/repos/index.ts +++ b/src/repos/index.ts @@ -1,11 +1,19 @@ -import { Db } from 'src/db'; -import LocationRepository from './LocationRepository'; +import Location from './Location'; +import Users from './Users'; +import UserRoles from './UserRoles'; +import UserSessions from './UserSessions'; class Repositories { - public location: LocationRepository; + public location: Location; + public users: Users; + public userRoles: UserRoles; + public userSessions: UserSessions; - constructor(db: Db) { - this.location = new LocationRepository(db); + constructor() { + this.location = new Location(); + this.users = new Users(); + this.userRoles = new UserRoles(); + this.userSessions = new UserSessions(); } } diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts index 442a775..a1701a6 100644 --- a/src/requests/LocationRequest.ts +++ b/src/requests/LocationRequest.ts @@ -2,6 +2,7 @@ import { Op } from 'sequelize'; import { Optional } from 'src/types'; import { Db } from 'src/db'; +import { ValidationError } from '../errors'; class LocationRequest { limit: Optional<number> = 250; @@ -34,7 +35,7 @@ class LocationRequest { if (req[key] != null) { const numValue = (this as any)[key] = parseInt(req[key]); if (isNaN(numValue)) { - throw new TypeError(`Invalid value for ${key}: ${req[key]}`); + throw new ValidationError(`Invalid value for ${key}: ${req[key]}`); } } } @@ -44,7 +45,7 @@ class LocationRequest { const numValue = (this as any)[key] = parseInt(req[key]); const dateValue = (this as any)[key] = new Date(isNaN(numValue) ? req[key] : numValue); if (isNaN(dateValue.getTime())) { - throw new TypeError(`Invalid value for ${key}: ${req[key]}`); + throw new ValidationError(`Invalid value for ${key}: ${req[key]}`); } } } diff --git a/src/routes/Route.ts b/src/routes/Route.ts index a5e3af6..36ae611 100644 --- a/src/routes/Route.ts +++ b/src/routes/Route.ts @@ -1,9 +1,20 @@ import { Express, Request, Response } from 'express'; +import { + BadRequest, + Forbidden, + NotFound, + Unauthorized, +} from '../errors'; + +import { AuthInfo } from '../auth'; +import { Optional, RequestHandler } from '../types'; import { logRequest } from '../helpers/logging'; abstract class Route { protected readonly path: string; + // Method -> Handler mapping + public static preRequestHandlers: Record<string, RequestHandler> = {}; constructor(path: string) { this.path = path; @@ -13,52 +24,99 @@ abstract class Route { res.status(405).send('Method Not Allowed'); } - private getRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => { - logRequest(req); - return await this.get(req, res); + protected static ServerError(req: Request, res: Response, error: Error) { + console.error(`Unhandled error in ${req.method} ${req.path}: ${error}`); + res.status(500).send('Internal Server Error'); } - private postRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => { - logRequest(req); - return await this.post(req, res); + protected static handleError(req: Request, res: Response, error: Error) { + if (error instanceof Unauthorized) { + const redirect = req.query?.redirect || '/'; + res.redirect(`/login?redirect=${redirect}`); + return; + } + + if (error instanceof Forbidden) { + res.status(403).send(error.message); + return; + } + + if (error instanceof NotFound) { + res.status(404).send(error.message); + return; + } + + if (error instanceof BadRequest) { + res.status(400).send(error.message); + return; + } + + Route.ServerError(req, res, error); } - private putRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => { + private handleRequest = async ( + req: Request, + res: Response, + handlerName: string, + ) => { logRequest(req); - return await this.put(req, res); + // @ts-expect-error + const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>); + const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName]; + + try { + let authInfo: Optional<AuthInfo> + if (preRequestHandler) { + authInfo = await preRequestHandler(req, res); + } + + handler(req, res, authInfo!); + } catch (error) { + (<typeof Route> this.constructor).handleError(req, res, error); + } } - private deleteRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => { - logRequest(req); - return await this.delete(req, res); + private getRoute = async (req: Request, res: Response) => { + await this.handleRequest(req, res, 'get'); } - private patchRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => { - logRequest(req); - return await this.patch(req, res); + private postRoute = async (req: Request, res: Response) => { + await this.handleRequest(req, res, 'post'); } - public get: (req: Request, res: Response) => Promise<void> = async (_, res) => { + private putRoute = async (req: Request, res: Response) => { + await this.handleRequest(req, res, 'put'); + } + + private deleteRoute = async (req: Request, res: Response) => { + await this.handleRequest(req, res, 'delete'); + } + + private patchRoute = async (req: Request, res: Response) => { + await this.handleRequest(req, res, 'patch'); + } + + public get = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => { Route.NotAllowed(res); return Promise.resolve(); } - public post: (req: Request, res: Response) => Promise<void> = async (_, res) => { + public post = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => { Route.NotAllowed(res); return Promise.resolve(); } - public put: (req: Request, res: Response) => Promise<void> = async (_, res) => { + public put = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => { Route.NotAllowed(res); return Promise.resolve(); } - public delete: (req: Request, res: Response) => Promise<void> = async (_, res) => { + public delete = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => { Route.NotAllowed(res); return Promise.resolve(); } - public patch: (req: Request, res: Response) => Promise<void> = async (_, res) => { + public patch = async (_: Request, res: Response, __: Optional<AuthInfo> = null) => { Route.NotAllowed(res); return Promise.resolve(); } diff --git a/src/routes/api/Route.ts b/src/routes/api/Route.ts index af9897b..d9669ad 100644 --- a/src/routes/api/Route.ts +++ b/src/routes/api/Route.ts @@ -1,4 +1,7 @@ +import { Request, Response } from 'express'; + import Route from '../Route'; +import { Unauthorized, } from '../../errors'; abstract class ApiRoute extends Route { protected version: string; @@ -7,6 +10,16 @@ abstract class ApiRoute extends Route { super(ApiRoute.toApiPath(path, version)); } + protected static handleError(req: Request, res: Response, error: Error) { + // Handle API unauthorized errors with a 401+JSON response instead of a redirect + if (error instanceof Unauthorized) { + res.status(401).send(error.message); + return; + } + + return super.handleError(req, res, error); + } + protected static toApiPath(path: string, version: string): string { if (!path.startsWith('/')) { path = `/${path}`; diff --git a/src/routes/api/v1/Auth.ts b/src/routes/api/v1/Auth.ts new file mode 100644 index 0000000..e4e2fbe --- /dev/null +++ b/src/routes/api/v1/Auth.ts @@ -0,0 +1,77 @@ +import { Optional } from '../../../types'; +import { Request, Response } from 'express'; + +import ApiV1Route from './Route'; +import { ValidationError } from '../../../errors'; +import { AuthInfo, authenticate } from '../../../auth'; + +class Auth extends ApiV1Route { + constructor() { + super('/auth'); + } + + /** + * Create a new session for the user. + * + * If the user is already authenticated (either through a cookie or a token), + * the existing session will be returned. Otherwise, a new session will be created. + * + * @param req: Request - The request object. + * @param res: Response - The response object. + * @param auth: Optional<AuthInfo> - The authentication information. + */ + @authenticate() + post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => { + const user = auth!.user; + let session = auth!.session; + const expiresAt = req.body?.expiresAt; + let expiresAtDate: Optional<Date> = null; + + if (session) { + console.debug('The user already has an active session or token'); + res.json({ + session: { + token: session.getToken(), + } + }); + + return; + } + + if (expiresAt) { + try { + expiresAtDate = new Date(expiresAt); + } catch (error) { + throw new ValidationError(`Invalid expiresAt: ${error}`); + } + } + + session = await $repos.userSessions.create(user.id, expiresAtDate); + res.setHeader('Set-Cookie', `session=${session.getToken()}; Path=/; HttpOnly; SameSite=Strict`); + res.json({ + session: { + token: session.getToken(), + } + }); + } + + /** + * Delete the current session. + * + * It requires the user to be authenticated (either through a cookie or a token). + * If the user is authenticated, the session will be destroyed and the cookie will be cleared. + */ + @authenticate() + delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => { + const session = auth!.session; + + if (session) { + await session.destroy(); + } + + res.setHeader('Set-Cookie', 'session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0'); + res.status(204).send(); + } +} + +export default Auth; diff --git a/src/routes/api/v1/GPSData.ts b/src/routes/api/v1/GPSData.ts index d38e23e..6a5814c 100644 --- a/src/routes/api/v1/GPSData.ts +++ b/src/routes/api/v1/GPSData.ts @@ -1,16 +1,15 @@ import { Request, Response } from 'express'; +import { authenticate } from '../../../auth'; import { LocationRequest } from '../../../requests'; -import LocationRepository from '~/repos/LocationRepository'; import ApiV1Route from './Route'; -const $location: LocationRepository = globalThis.$repos.location; - class GPSData extends ApiV1Route { constructor() { super('/gpsdata'); } + @authenticate() get = async (req: Request, res: Response) => { let query: LocationRequest @@ -24,7 +23,7 @@ class GPSData extends ApiV1Route { } try { - const gpsData = await $location.getHistory(query); + const gpsData = await $repos.location.getHistory(query); res.json(gpsData); } catch (error) { const e = `Error fetching data: ${error}`; diff --git a/src/routes/api/v1/UserSelf.ts b/src/routes/api/v1/UserSelf.ts new file mode 100644 index 0000000..fee7b28 --- /dev/null +++ b/src/routes/api/v1/UserSelf.ts @@ -0,0 +1,30 @@ +import { Optional } from '../../../types'; +import { Request, Response } from 'express'; + +import ApiV1Route from './Route'; +import { AuthInfo, authenticate } from '../../../auth'; +import { maskPassword } from '../../../helpers/security'; + +class UserSelf extends ApiV1Route { + constructor() { + super('/users/me'); + } + + /** + * GET /users/me + * + * It returns a JSON object with the user and session information. + */ + @authenticate() + get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => { + const user = auth!.user; + const session = auth!.session; + + res.json({ + user: maskPassword(user), + ...(session ? { session } : {}), + }); + } +} + +export default UserSelf; diff --git a/src/routes/api/v1/index.ts b/src/routes/api/v1/index.ts index 0c13d9c..3832ced 100644 --- a/src/routes/api/v1/index.ts +++ b/src/routes/api/v1/index.ts @@ -1,8 +1,14 @@ +import Auth from "./Auth"; import GPSData from "./GPSData"; import Routes from "../../Routes"; +import UserSelf from "./UserSelf"; class ApiV1Routes extends Routes { - public routes = [new GPSData()]; + public routes = [ + new Auth(), + new GPSData(), + new UserSelf(), + ]; } export default ApiV1Routes; diff --git a/src/types.ts b/src/types.ts index dec3aa8..538e401 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,11 @@ -type Optional<T> = T | null | undefined; +import { Request, Response } from 'express'; -export { Optional }; +import { AuthInfo } from './auth'; + +type Optional<T> = T | null | undefined; +type RequestHandler = (req: Request, res: Response) => Promise<AuthInfo>; + +export { + Optional, + RequestHandler, +};