Skip to content

Commit

Permalink
Add a password set/reset script (#186)
Browse files Browse the repository at this point in the history
Co-authored-by: Matiss Janis Aboltins <[email protected]>
  • Loading branch information
j-f1 and MatissJanis authored Apr 16, 2023
1 parent bc93604 commit 104c980
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 73 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build": "tsc",
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage",
"types": "tsc --noEmit --incremental",
"verify": "yarn -s lint && yarn types"
"verify": "yarn -s lint && yarn types",
"reset-password": "node src/scripts/reset-password.js"
},
"dependencies": {
"@actual-app/api": "5.1.2",
Expand Down
83 changes: 78 additions & 5 deletions src/account-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { join } from 'node:path';
import openDatabase from './db.js';
import config, { sqlDir } from './load-config.js';
import createDebug from 'debug';
import * as uuid from 'uuid';
import * as bcrypt from 'bcrypt';

const debug = createDebug('actual:account-db');

let accountDb = null;
let _accountDb = null;

export default function getAccountDb() {
if (accountDb == null) {
if (_accountDb == null) {
if (!fs.existsSync(config.serverFiles)) {
debug(`creating server files directory: '${config.serverFiles}'`);
fs.mkdirSync(config.serverFiles);
Expand All @@ -18,16 +20,87 @@ export default function getAccountDb() {
let dbPath = join(config.serverFiles, 'account.sqlite');
let needsInit = !fs.existsSync(dbPath);

accountDb = openDatabase(dbPath);
_accountDb = openDatabase(dbPath);

if (needsInit) {
debug(`initializing account database: '${dbPath}'`);
let initSql = fs.readFileSync(join(sqlDir, 'account.sql'), 'utf8');
accountDb.exec(initSql);
_accountDb.exec(initSql);
} else {
debug(`opening account database: '${dbPath}'`);
}
}

return accountDb;
return _accountDb;
}

function hashPassword(password) {
return bcrypt.hashSync(password, 12);
}

export function needsBootstrap() {
let accountDb = getAccountDb();
let rows = accountDb.all('SELECT * FROM auth');
return rows.length === 0;
}

export function bootstrap(password) {
let accountDb = getAccountDb();

let rows = accountDb.all('SELECT * FROM auth');
if (rows.length !== 0) {
return { error: 'already-bootstrapped' };
}

if (password == null || password === '') {
return { error: 'invalid-password' };
}

// Hash the password. There's really not a strong need for this
// since this is a self-hosted instance owned by the user.
// However, just in case we do it.
let hashed = hashPassword(password);
accountDb.mutate('INSERT INTO auth (password) VALUES (?)', [hashed]);

let token = uuid.v4();
accountDb.mutate('INSERT INTO sessions (token) VALUES (?)', [token]);
return { token };
}

export function login(password) {
let accountDb = getAccountDb();
let row = accountDb.first('SELECT * FROM auth');
let confirmed = row && bcrypt.compareSync(password, row.password);

if (confirmed) {
// Right now, tokens are permanent and there's just one in the
// system. In the future this should probably evolve to be a
// "session" that times out after a long time or something, and
// maybe each device has a different token
let row = accountDb.first('SELECT * FROM sessions');
return row.token;
} else {
return null;
}
}

export function changePassword(newPassword) {
let accountDb = getAccountDb();

if (newPassword == null || newPassword === '') {
return { error: 'invalid-password' };
}

let hashed = hashPassword(newPassword);
let token = uuid.v4();
// Note that this doesn't have a WHERE. This table only ever has 1
// row (maybe that will change in the future? if so this will not work)
accountDb.mutate('UPDATE auth SET password = ?', [hashed]);
accountDb.mutate('UPDATE sessions SET token = ?', [token]);
return {};
}

export function getSession(token) {
let accountDb = getAccountDb();
return accountDb.first('SELECT * FROM sessions WHERE token = ?', [token]);
}
78 changes: 16 additions & 62 deletions src/app-account.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import express from 'express';
import * as bcrypt from 'bcrypt';
import * as uuid from 'uuid';
import errorMiddleware from './util/error-middleware.js';
import validateUser from './util/validate-user.js';
import getAccountDb from './account-db.js';
import {
bootstrap,
login,
changePassword,
needsBootstrap,
} from './account-db.js';

let app = express();
app.use(errorMiddleware);
Expand All @@ -14,95 +17,46 @@ export function init() {
// eslint-disable-previous-line @typescript-eslint/no-empty-function
}

function hashPassword(password) {
return bcrypt.hashSync(password, 12);
}

// Non-authenticated endpoints:
//
// /needs-bootstrap
// /boostrap (special endpoint for setting up the instance, cant call again)
// /login

app.get('/needs-bootstrap', (req, res) => {
let accountDb = getAccountDb();
let rows = accountDb.all('SELECT * FROM auth');

res.send({
status: 'ok',
data: { bootstrapped: rows.length > 0 },
data: { bootstrapped: !needsBootstrap() },
});
});

app.post('/bootstrap', (req, res) => {
let { password } = req.body;
let accountDb = getAccountDb();

let rows = accountDb.all('SELECT * FROM auth');
if (rows.length !== 0) {
res.status(400).send({
status: 'error',
reason: 'already-bootstrapped',
});
return;
}
let { error, token } = bootstrap(req.body.password);

if (password == null || password === '') {
res.status(400).send({ status: 'error', reason: 'invalid-password' });
if (error) {
res.status(400).send({ status: 'error', reason: error });
return;
} else {
res.send({ status: 'ok', data: { token } });
}

// Hash the password. There's really not a strong need for this
// since this is a self-hosted instance owned by the user.
// However, just in case we do it.
let hashed = hashPassword(password);
accountDb.mutate('INSERT INTO auth (password) VALUES (?)', [hashed]);

let token = uuid.v4();
accountDb.mutate('INSERT INTO sessions (token) VALUES (?)', [token]);

res.send({ status: 'ok', data: { token } });
});

app.post('/login', (req, res) => {
let { password } = req.body;
let accountDb = getAccountDb();

let row = accountDb.first('SELECT * FROM auth');
let confirmed = row && bcrypt.compareSync(password, row.password);

let token = null;
if (confirmed) {
// Right now, tokens are permanent and there's just one in the
// system. In the future this should probably evolve to be a
// "session" that times out after a long time or something, and
// maybe each device has a different token
let row = accountDb.first('SELECT * FROM sessions');
token = row.token;
}

let token = login(req.body.password);
res.send({ status: 'ok', data: { token } });
});

app.post('/change-password', (req, res) => {
let user = validateUser(req, res);
if (!user) return;

let accountDb = getAccountDb();
let { password } = req.body;
let { error } = changePassword(req.body.password);

if (password == null || password === '') {
res.send({ status: 'error', reason: 'invalid-password' });
if (error) {
res.send({ status: 'error', reason: error });
return;
}

let hashed = hashPassword(password);
let token = uuid.v4();
// Note that this doesn't have a WHERE. This table only ever has 1
// row (maybe that will change in the future? if this this will not work)
accountDb.mutate('UPDATE auth SET password = ?', [hashed]);
accountDb.mutate('UPDATE sessions SET token = ?', [token]);

res.send({ status: 'ok', data: {} });
});

Expand Down
36 changes: 36 additions & 0 deletions src/scripts/reset-password.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { needsBootstrap, bootstrap, changePassword } from '../account-db.js';
import { promptPassword } from '../util/prompt.js';

if (needsBootstrap()) {
console.log(
'It looks like you don’t have a password set yet. Let’s set one up now!',
);

promptPassword().then((password) => {
let { error } = bootstrap(password);
if (error) {
console.log('Error setting password:', error);
console.log(
'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
);
} else {
console.log('Password set!');
}
});
} else {
console.log('It looks like you already have a password set. Let’s reset it!');
promptPassword().then((password) => {
let { error } = changePassword(password);
if (error) {
console.log('Error changing password:', error);
console.log(
'Please report this as an issue: https://github.com/actualbudget/actual-server/issues',
);
} else {
console.log('Password changed!');
console.log(
'Note: you will need to log in with the new password on any browsers or devices that are currently logged in.',
);
}
});
}
88 changes: 88 additions & 0 deletions src/util/prompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createInterface, cursorTo } from 'node:readline';

export async function prompt(message) {
let rl = createInterface({
input: process.stdin,
output: process.stdout,
});

let promise = new Promise((resolve) => {
rl.question(message, (answer) => {
resolve(answer);
rl.close();
});
});

let answer = await promise;

return answer;
}

export async function promptPassword() {
let password = await askForPassword('Enter a password, then press enter: ');

if (password === '') {
console.log('Password cannot be empty.');
return promptPassword();
}

let password2 = await askForPassword(
'Enter the password again, then press enter: ',
);

if (password !== password2) {
console.log('Passwords do not match.');
return promptPassword();
}

return password;
}

async function askForPassword(prompt) {
let dataListener, endListener;

let promise = new Promise((resolve) => {
let result = '';
process.stdout.write(prompt);
process.stdin.setRawMode(true);
process.stdin.resume();
dataListener = (key) => {
switch (key[0]) {
case 0x03: // ^C
process.exit();
break;
case 0x0d: // Enter
process.stdin.setRawMode(false);
process.stdin.pause();
resolve(result);
break;
case 0x7f: // Backspace
case 0x08: // Delete
if (result) {
result = result.slice(0, -1);
cursorTo(process.stdout, prompt.length + result.length);
process.stdout.write(' ');
cursorTo(process.stdout, prompt.length + result.length);
}
break;
default:
result += key;
process.stdout.write('*');
break;
}
};
process.stdin.on('data', dataListener);

endListener = () => resolve(result);
process.stdin.on('end', endListener);
});

let answer = await promise;

process.stdin.off('data', dataListener);
process.stdin.off('end', endListener);

process.stdout.write('\n');

return answer;
}
9 changes: 4 additions & 5 deletions src/util/validate-user.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import getAccountDb from '../account-db.js';
import { getSession } from '../account-db.js';

/**
* @param {import('express').Request} req
Expand All @@ -11,10 +11,9 @@ export default function validateUser(req, res) {
token = req.headers['x-actual-token'];
}

let db = getAccountDb();
let rows = db.all('SELECT * FROM sessions WHERE token = ?', [token]);
let session = getSession(token);

if (rows.length === 0) {
if (!session) {
res.status(401);
res.send({
status: 'error',
Expand All @@ -24,5 +23,5 @@ export default function validateUser(req, res) {
return null;
}

return rows[0];
return session;
}
6 changes: 6 additions & 0 deletions upcoming-release-notes/186.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Features
authors: [j-f1]
---

Add an `npm run reset-password` script to set or reset the server password.

0 comments on commit 104c980

Please sign in to comment.