Switch to json web tokens.

This commit is contained in:
Mike Cao 2020-07-22 20:45:09 -07:00
parent 5219582803
commit cb0c912c5b
10 changed files with 202 additions and 86 deletions

View File

@ -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);
}

View File

@ -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,
},
}),
);
}

View File

@ -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(':');
}
}
};

View File

@ -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;
}

View File

@ -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",

View File

@ -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('');
}

View File

@ -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 });
};

View File

@ -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[]

View File

@ -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);

View File

@ -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"