diff --git a/package.json b/package.json index f8f442db5..4d5e86d52 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/account-db.js b/src/account-db.js index c210641ec..3c1e5845c 100644 --- a/src/account-db.js +++ b/src/account-db.js @@ -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); @@ -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]); } diff --git a/src/app-account.js b/src/app-account.js index 0e0122cd0..a5cd0b8a2 100644 --- a/src/app-account.js +++ b/src/app-account.js @@ -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); @@ -14,10 +17,6 @@ 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 @@ -25,62 +24,25 @@ function hashPassword(password) { // /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 } }); }); @@ -88,21 +50,13 @@ 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: {} }); }); diff --git a/src/scripts/reset-password.js b/src/scripts/reset-password.js new file mode 100644 index 000000000..26d5b1638 --- /dev/null +++ b/src/scripts/reset-password.js @@ -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.', + ); + } + }); +} diff --git a/src/util/prompt.js b/src/util/prompt.js new file mode 100644 index 000000000..f66f0f760 --- /dev/null +++ b/src/util/prompt.js @@ -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; +} diff --git a/src/util/validate-user.js b/src/util/validate-user.js index c94aa2b5e..9cb319563 100644 --- a/src/util/validate-user.js +++ b/src/util/validate-user.js @@ -1,4 +1,4 @@ -import getAccountDb from '../account-db.js'; +import { getSession } from '../account-db.js'; /** * @param {import('express').Request} req @@ -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', @@ -24,5 +23,5 @@ export default function validateUser(req, res) { return null; } - return rows[0]; + return session; } diff --git a/upcoming-release-notes/186.md b/upcoming-release-notes/186.md new file mode 100644 index 000000000..0edeabf9f --- /dev/null +++ b/upcoming-release-notes/186.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [j-f1] +--- + +Add an `npm run reset-password` script to set or reset the server password.