diff --git a/lib/crypto.js b/lib/crypto.js index 3b9a3818..4d8449df 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -1,31 +1,38 @@ import crypto from 'crypto'; -import { v5 as uuid, v4 } from 'uuid'; -import Cryptr from 'cryptr'; +import { v5 } from 'uuid'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/; -const cryptr = new Cryptr(hash(process.env.HASH_SALT, process.env.DATABASE_URL)); - -export function md5(s) { - return crypto.createHash('md5').update(s).digest('hex'); +export function sha256(...args) { + return crypto.createHash('sha256').update(args.join('')).digest('hex'); } -export function hash(...args) { - return uuid(args.join(''), md5(process.env.HASH_SALT)); +export function secret() { + return sha256(process.env.HASH_SALT); } -export function validHash(s) { +export function uuid(...args) { + return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS)); +} + +export function random(n = 64) { + return crypto.randomBytes(n).toString('hex'); +} + +export function isValidHash(s) { return UUID_REGEX.test(s); } -export function encrypt(s) { - return cryptr.encrypt(s); +export async function createToken(payload, options) { + return jwt.sign(payload, secret(), options); } -export function decrypt(s) { - return cryptr.decrypt(s); +export async function parseToken(token, options) { + return jwt.verify(token, secret(), options); } -export function random() { - return v4(); +export function checkPassword(password, hash) { + return bcrypt.compare(password, hash); } diff --git a/lib/db.js b/lib/db.js index 2fdc49bf..8649b788 100644 --- a/lib/db.js +++ b/lib/db.js @@ -95,3 +95,13 @@ export async function saveEvent(website_id, session_id, url, event_type, event_v }), ); } + +export async function getAccount(username = '') { + return runQuery( + prisma.account.findOne({ + where: { + username, + }, + }), + ); +} diff --git a/lib/session.js b/lib/session.js index 3a1485df..8ecc6cec 100644 --- a/lib/session.js +++ b/lib/session.js @@ -1,50 +1,52 @@ import { getWebsite, getSession, createSession } from 'lib/db'; -import { getCountry, getDevice, getIpAddress, isValidSession } from 'lib/utils'; -import { hash } from 'lib/crypto'; +import { getCountry, getDevice, getIpAddress } from 'lib/utils'; +import { uuid, parseToken, isValidHash } from 'lib/crypto'; export default async req => { const { payload } = req.body; - const { session } = payload; + const { website: website_uuid, hostname, screen, language, session } = payload; - if (isValidSession(session)) { - return session; + if (!isValidHash(website_uuid)) { + throw new Error(`Invalid website: ${website_uuid}`); } - const ip = getIpAddress(req); - const { userAgent, browser, os } = getDevice(req); - const country = await getCountry(req, ip); - const { website: website_uuid, hostname, screen, language } = payload; + try { + return await parseToken(session); + } catch { + const ip = getIpAddress(req); + const { userAgent, browser, os } = getDevice(req); + const country = await getCountry(req, ip); - if (website_uuid) { - const website = await getWebsite(website_uuid); + if (website_uuid) { + const website = await getWebsite(website_uuid); - if (website) { - const { website_id } = website; - const session_uuid = hash(website_id, hostname, ip, userAgent, os); + if (website) { + const { website_id } = website; + const session_uuid = uuid(website_id, hostname, ip, userAgent, os); - let session = await getSession(session_uuid); + let session = await getSession(session_uuid); - if (!session) { - session = await createSession(website_id, { + if (!session) { + session = await createSession(website_id, { + session_uuid, + hostname, + browser, + os, + screen, + language, + country, + }); + } + + const { session_id } = session; + + return { + website_id, + website_uuid, + session_id, session_uuid, - hostname, - browser, - os, - screen, - language, - country, - }); + }; } - - const { session_id } = session; - - return [ - website_id, - website_uuid, - session_id, - session_uuid, - hash(website_id, website_uuid, session_id, session_uuid), - ].join(':'); } } }; diff --git a/lib/utils.js b/lib/utils.js index b57537f6..b92cda53 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,9 +1,8 @@ import requestIp from 'request-ip'; import { browserName, detectOS } from 'detect-browser'; +import isLocalhost from 'is-localhost-ip'; import maxmind from 'maxmind'; import geolite2 from 'geolite2-redist'; -import isLocalhost from 'is-localhost-ip'; -import { hash } from './crypto'; export function getIpAddress(req) { // Cloudflare @@ -44,20 +43,3 @@ export async function getCountry(req, ip) { return result.country.iso_code; } - -export function parseSession(session) { - const [website_id, website_uuid, session_id, session_uuid, sig] = (session || '').split(':'); - return { - website_id: parseInt(website_id), - website_uuid, - session_id: parseInt(session_id), - session_uuid, - sig, - }; -} - -export function isValidSession(session) { - const { website_id, website_uuid, session_id, session_uuid, sig } = parseSession(session); - - return hash(website_id, website_uuid, session_id, session_uuid) === sig; -} diff --git a/package.json b/package.json index 1a8d1cc9..85f5e05b 100644 --- a/package.json +++ b/package.json @@ -29,17 +29,18 @@ }, "dependencies": { "@prisma/client": "2.2.2", + "base64url": "^3.0.1", "bcrypt": "^5.0.0", "chart.js": "^2.9.3", "classnames": "^2.2.6", "cookie": "^0.4.1", "cors": "^2.8.5", - "cryptr": "^6.0.2", "date-fns": "^2.14.0", "detect-browser": "^5.1.1", "dotenv": "^8.2.0", "geolite2-redist": "^1.0.7", "is-localhost-ip": "^1.4.0", + "jsonwebtoken": "^8.5.1", "maxmind": "^4.1.3", "next": "9.3.5", "node-fetch": "^2.6.0", diff --git a/pages/api/auth.js b/pages/api/auth.js index 60ecc90f..bfc33f33 100644 --- a/pages/api/auth.js +++ b/pages/api/auth.js @@ -1,18 +1,21 @@ import { serialize } from 'cookie'; -import { hash, random, encrypt } from 'lib/crypto'; +import { checkPassword, createToken, secret } from 'lib/crypto'; +import { getAccount } from 'lib/db'; -export default (req, res) => { - const { password } = req.body; +export default async (req, res) => { + const { username, password } = req.body; - if (password === process.env.PASSWORD) { + const account = await getAccount(username); + + if (account && (await checkPassword(password, account.password))) { + const { user_id, username, is_admin } = account; + const token = await createToken({ user_id, username, is_admin }); const expires = new Date(Date.now() + 31536000000); - const id = random(); - const value = encrypt(`${id}:${hash(id)}`); - - const cookie = serialize('umami.auth', value, { expires, httpOnly: true }); + const cookie = serialize('umami.auth', token, { expires, httpOnly: true }); res.setHeader('Set-Cookie', [cookie]); - res.status(200).send(''); + + res.status(200).send({ token }); } else { res.status(401).send(''); } diff --git a/pages/api/collect.js b/pages/api/collect.js index 1e12c083..bfcc4405 100644 --- a/pages/api/collect.js +++ b/pages/api/collect.js @@ -1,14 +1,14 @@ -import { parseSession } from 'lib/utils'; import { savePageView, saveEvent } from 'lib/db'; import { allowPost } from 'lib/middleware'; import checkSession from 'lib/session'; +import { createToken } from 'lib/crypto'; export default async (req, res) => { await allowPost(req, res); const session = await checkSession(req); - const { website_id, session_id } = parseSession(session); + const { website_id, session_id } = session; const { type, payload } = req.body; let ok = 1; @@ -26,5 +26,7 @@ export default async (req, res) => { }); } - res.status(200).json({ ok, session }); + const token = await createToken(session); + + res.status(200).json({ ok, session: token }); }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dabebba4..b3cd714d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,16 @@ datasource db { url = env("DATABASE_URL") } +model account { + created_at DateTime? @default(now()) + is_admin Boolean @default(false) + password String + updated_at DateTime? @default(now()) + user_id Int @default(autoincrement()) @id + username String @unique + website website[] +} + model event { created_at DateTime? @default(now()) event_id Int @default(autoincrement()) @id @@ -19,6 +29,8 @@ model event { website website @relation(fields: [website_id], references: [website_id]) @@index([created_at], name: "event_created_at_idx") + @@index([session_id], name: "event_session_id_idx") + @@index([website_id], name: "event_website_id_idx") } model pageview { @@ -32,6 +44,8 @@ model pageview { website website @relation(fields: [website_id], references: [website_id]) @@index([created_at], name: "pageview_created_at_idx") + @@index([session_id], name: "pageview_session_id_idx") + @@index([website_id], name: "pageview_website_id_idx") } model session { @@ -50,13 +64,16 @@ model session { pageview pageview[] @@index([created_at], name: "session_created_at_idx") + @@index([website_id], name: "session_website_id_idx") } model website { created_at DateTime? @default(now()) hostname String + user_id Int website_id Int @default(autoincrement()) @id website_uuid String @unique + account account @relation(fields: [user_id], references: [user_id]) event event[] pageview pageview[] session session[] diff --git a/sql/schema.postgresql.sql b/sql/schema.postgresql.sql index e3c740bf..b8fa83f4 100644 --- a/sql/schema.postgresql.sql +++ b/sql/schema.postgresql.sql @@ -1,6 +1,16 @@ +create table account ( + user_id serial primary key, + username varchar(255) unique not null, + password varchar(60) not null, + is_admin bool not null default false, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp +); + create table website ( website_id serial primary key, website_uuid uuid unique not null, + user_id int not null references account(user_id) on delete cascade, hostname varchar(100) not null, created_at timestamp with time zone default current_timestamp ); @@ -37,6 +47,8 @@ create table event ( event_value varchar(50) not null ); +create index on account(username); + create index on session(created_at); create index on session(website_id); diff --git a/yarn.lock b/yarn.lock index 352d96f8..02583387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1937,6 +1937,11 @@ base64-js@^1.0.2: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64url@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + base@^0.11.1: version "0.11.2" resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" @@ -2129,6 +2134,11 @@ buble@^0.20.0: minimist "^1.2.5" regexpu-core "4.5.4" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -2759,11 +2769,6 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" -cryptr@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/cryptr/-/cryptr-6.0.2.tgz#3f9e97f825ffb93f425eb24068efbb6a652bf947" - integrity sha512-1TRHI4bmuLIB8WgkH9eeYXzhEg1T4tonO4vVaMBKKde8Dre51J68nAgTVXTwMYXAf7+mV2gBCkm/9wksjSb2sA== - css-blank-pseudo@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" @@ -3207,6 +3212,13 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + electron-to-chromium@^1.3.322, electron-to-chromium@^1.3.488: version "1.3.496" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.496.tgz#3f43d32930481d82ad3663d79658e7c59a58af0b" @@ -4790,6 +4802,22 @@ json5@^2.1.0, json5@^2.1.2: dependencies: minimist "^1.2.5" +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" + jsx-ast-utils@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.4.1.tgz#1114a4c1209481db06c690c2b4f488cc665f657e" @@ -4798,6 +4826,23 @@ jsx-ast-utils@^2.4.1: array-includes "^3.1.1" object.assign "^4.1.0" +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" + +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" + kind-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" @@ -4979,6 +5024,36 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +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.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.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -4989,6 +5064,11 @@ lodash.merge@^4.6.0: 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.template@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"