diff --git a/angular.json b/angular.json index 4d99da3..5701823 100644 --- a/angular.json +++ b/angular.json @@ -13,7 +13,7 @@ "build": { "builder": "@angular-devkit/build-angular:browser", "options": { - "outputPath": "dist/twigs", + "outputPath": "dist/public", "index": "src/client/index.html", "main": "src/client/main.ts", "polyfills": "src/client/polyfills.ts", diff --git a/package-lock.json b/package-lock.json index 9363577..e051349 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18817,6 +18817,12 @@ "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", "dev": true }, + "@types/bcrypt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-3.0.1.tgz", + "integrity": "sha512-SwBrq5wb6jXP0o3O3jStdPWbKpimTImfdFD/OZE3uW+jhGpds/l5wMX9lfYOTDOa5Bod2QmOgo9ln+tMp2XP/w==", + "dev": true + }, "@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -19669,6 +19675,80 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "dev": true }, + "bcrypt": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz", + "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==", + "dev": true, + "requires": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^3.1.0" + }, + "dependencies": { + "@mapbox/node-pre-gyp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", + "integrity": "sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==", + "dev": true, + "requires": { + "detect-libc": "^1.0.3", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.1", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "rimraf": "^3.0.2", + "semver": "^7.3.4", + "tar": "^6.1.0" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -24426,6 +24506,12 @@ "whatwg-url": "^5.0.0" } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "dev": true + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", diff --git a/package.json b/package.json index 482aca0..a6f4697 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,14 @@ "@angular/cli": "^14.0.4", "@angular/compiler-cli": "^14.0.4", "@angular/language-service": "^14.0.4", + "@types/bcrypt": "^3.0.0", "@types/express": "^4.17.11", + "@types/express-serve-static-core": "^4.17.18", "@types/jasmine": "~3.10.3", "@types/jasminewd2": "^2.0.10", "@types/node": "^17.0.10", "@types/sqlite3": "^3.1.7", + "bcrypt": "^5.0.0", "concurrently": "^5.3.0", "eslint": "^8.7.0", "express": "^4.17.1", diff --git a/src/client/environments/environment.ts b/src/client/environments/environment.ts index 92b443e..e07c867 100644 --- a/src/client/environments/environment.ts +++ b/src/client/environments/environment.ts @@ -4,7 +4,7 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8080/api' + apiUrl: 'http://localhost:3000/api' }; /* diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 0411d66..33b53c1 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -5,7 +5,8 @@ import * as migrations from './migrations'; export function db(dataDir: string): sqlite3.Database { const dbPath = path.join(dataDir, "twigs.db"); console.log(`Initializing database at ${dbPath}`) - const db = new sqlite3.Database(dbPath, (err?: Error) => { + const sqlite3verbose = sqlite3.verbose(); + const db = new sqlite3verbose.Database(dbPath, (err?: Error) => { if (err != null) { console.error("Failed to open db"); console.error(err); diff --git a/src/server/index.ts b/src/server/index.ts index b005156..63bf1ad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,7 +3,7 @@ import { router as budgetRouter } from './budget/controller' import { router as categoryRouter } from './categories/controller' import { router as permissionsRouter } from './permissions/controller' import { router as transactionRouter } from './transactions/controller' -import { router as userRouter } from './users/controller' +import { userRouter } from './users' import { db as _db } from './db'; const port = process.env.PORT || 3000; @@ -12,6 +12,8 @@ const app = express(); const dataDir = process.env.TWIGS_DATA || __dirname; const db = _db(dataDir); +app.use(express.json()); + app.use(express.static(__dirname + '/public')); // app.get('/', (req, res) => { @@ -23,7 +25,7 @@ app.use('/api/budgets', budgetRouter); app.use('/api/categories', categoryRouter); app.use('/api/permissions', permissionsRouter); app.use('/api/transactions', transactionRouter); -app.use('/api/users', userRouter); +app.use('/api/users', userRouter(db)); app.get('/*', (req, res) => { res.sendFile(__dirname + '/public/index.html'); diff --git a/src/server/types/express/index.d.ts b/src/server/types/express/index.d.ts new file mode 100644 index 0000000..b1b9762 --- /dev/null +++ b/src/server/types/express/index.d.ts @@ -0,0 +1,9 @@ +import { User } from "../../users/user"; +import { Request } from 'express'; + +declare module "express-serve-static-core" { + interface Request { + user?: User; + } + } + \ No newline at end of file diff --git a/src/server/users/controller.ts b/src/server/users/controller.ts deleted file mode 100644 index e297e82..0000000 --- a/src/server/users/controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import express from 'express'; - -const router = express.Router() - -router.get('/', (req, res) => { - -}); - -router.post('/', (req, res) => { - -}); - -router.get('/:id', (req, res) => { - -}); - -router.put('/:id', (req, res) => { - -}); - -router.delete('/:id', (req, res) => { - -}); - -export { router }; \ No newline at end of file diff --git a/src/server/users/index.ts b/src/server/users/index.ts new file mode 100644 index 0000000..072ef3f --- /dev/null +++ b/src/server/users/index.ts @@ -0,0 +1,3 @@ +import userRouter from './user_controller'; + +export { userRouter }; \ No newline at end of file diff --git a/src/server/users/user.ts b/src/server/users/user.ts new file mode 100644 index 0000000..b37c49d --- /dev/null +++ b/src/server/users/user.ts @@ -0,0 +1,37 @@ +import { randomId } from '../utils'; + +export class User { + id: string = randomId(); + username: string; + email: string; + password: string; + + constructor( + id: string, + username: string, + email: string, + password?: string + ) { + this.id = id; + this.username = username; + this.email = email; + this.password = password; + } +} + +function twoWeeksFromNow(): Date { + const date = new Date(); + date.setDate(date.getDate() + 14); + return date; +} + +export class Session { + id: string = randomId(); + userId: string; + token: string = randomId(256); + expiration: Date = twoWeeksFromNow(); + + constructor(userId: string) { + this.userId = userId; + } +} \ No newline at end of file diff --git a/src/server/users/user_controller.ts b/src/server/users/user_controller.ts new file mode 100644 index 0000000..6a791ad --- /dev/null +++ b/src/server/users/user_controller.ts @@ -0,0 +1,101 @@ +import express from 'express'; +import sqlite3 from 'sqlite3'; +import { authMiddleware, randomId } from '../utils'; +import { hashSync, compareSync } from 'bcrypt'; +import { Session } from './user'; + +const SALT_ROUNDS = 10; + +export default function userRouter(db: sqlite3.Database): express.Router { + const router = express.Router() + + router.post('/login', (req, res) => { + const username = req.body.username; + const password = req.body.password; + db.prepare('SELECT * FROM user WHERE username = ?') + .get(username, (err?: Error, row?: any) => { + if (err) { + console.error(err) + res.status(500).send("Login failed") + return; + } + if (!row) { + res.status(401).send({ 'message': 'Invalid credentials' }) + return; + } + if (!compareSync(password, row.password)) { + res.status(401).send({ 'message': 'Invalid credentials' }) + return; + } + const session = new Session(row.id); + db.prepare('INSERT INTO session (id, user_id, token, expiration) VALUES (?, ?, ?, ?)') + .run([session.id, session.userId, session.token, session.expiration], (sessionErr?: Error) => { + if (sessionErr) { + console.error(err) + res.status(500).send("Session creation failed") + return; + } + res.send({ + token: session.token, + expiration: session.expiration.toISOString() + }); + }) + .finalize(); + }) + .finalize(); + }); + + router.get('/', (req, res) => { + res.status(500).send("GET /"); + }); + + router.post('/', (req, res) => { + if (!req.body.username || !req.body.password) { + res.status(400).send("Username and password are required fields"); + return; + } + let id = req.body.id; + if (!id || id.length < 32) { + id = randomId(); + } + const email = req.body.email; + const username = req.body.username; + const password = hashSync(req.body.password, SALT_ROUNDS); + db.prepare('INSERT INTO user (id, username, email, password) VALUES (?, ?, ?, ?)') + .run([id, username, email, password], (err?: Error) => { + if (err) { + console.error(err) + res.status(500).send("Registration failed") + return; + } + res.send({ + id: id, + username: username, + email: email + }); + }) + .finalize(); + }); + + router.get('/me', authMiddleware(db), (req, res) => { + res.send({ + id: req.user.id, + username: req.user.username, + email: req.user.email + }) + }); + + router.get('/:id', (req, res) => { + res.status(500).send("GET /:id"); + }); + + router.put('/:id', (req, res) => { + res.status(500).send("PUT /:id"); + }); + + router.delete('/:id', (req, res) => { + res.status(500).send("DELETE /:id"); + }); + + return router; +} diff --git a/src/server/utils.ts b/src/server/utils.ts index b391a58..e6f656c 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -1,7 +1,45 @@ import { randomInt } from 'crypto'; +import { Request, Response, NextFunction } from 'express'; +import { ParamsDictionary } from 'express-serve-static-core'; +import QueryString from 'qs'; +import sqlite3 from 'sqlite3'; +import { User } from './users/user'; const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' export function randomId(length = 32): string { - return Array.from(new Array(length), () => { CHARACTERS[randomInt(CHARACTERS.length)] }).join(''); + return Array.from(new Array(length), () => CHARACTERS[randomInt(CHARACTERS.length)]).join(''); +} + +export function authMiddleware(db: sqlite3.Database): ( + req: Request>, + res: Response>, + next: NextFunction +) => void { + return (req, res, next) => { + const auth = req.get('Authorization'); + if (!auth) { + res.status(401).send(); + return; + } + const token = auth.substring(7); + db.prepare('SELECT U.id, U.username, U.email FROM user U INNER JOIN session S ON S.user_id = U.id WHERE S.token = ?') + .get(token, (err, row) => { + if (err) { + console.error(`Auth error: ${err}`) + res.status(401).send(); + } else if (!row) { + console.log("Invalid session token") + res.status(401).send("Invalid session token") + } else { + console.log(`Found user for token: ${row}`); + req.user = new User( + row.id, + row.username, + row.email + ); + next(); + } + }); + } } \ No newline at end of file