From 61c3884eef4ac32ff16371395d008799b527370d Mon Sep 17 00:00:00 2001 From: lelemm Date: Sat, 23 Nov 2024 08:55:56 -0300 Subject: [PATCH] OpenID (#498) --- jest.global-setup.js | 92 +++- migrations/1718889148000-openid.js | 41 ++ migrations/1719409568000-multiuser.js | 104 +++++ package.json | 1 + src/account-db.js | 186 ++++++-- src/accounts/openid.js | 316 ++++++++++++++ src/accounts/password.js | 124 ++++++ src/app-account.js | 83 +++- src/app-admin.js | 409 ++++++++++++++++++ src/app-admin.test.js | 380 ++++++++++++++++ src/app-gocardless/app-gocardless.js | 4 +- src/app-openid.js | 101 +++++ src/app-secrets.js | 34 +- src/app-sync.js | 28 +- src/app-sync.test.js | 38 +- src/app-sync/services/files-service.js | 58 ++- .../tests/services/files-service.test.js | 41 +- src/app.js | 6 + src/config-types.ts | 17 +- src/load-config.js | 60 +++ src/scripts/reset-password.js | 43 +- src/services/user-service.js | 261 +++++++++++ src/util/middlewares.js | 12 +- src/util/validate-user.js | 19 +- tsconfig.json | 1 + upcoming-release-notes/498.md | 6 + yarn.lock | 43 ++ 27 files changed, 2391 insertions(+), 117 deletions(-) create mode 100644 migrations/1718889148000-openid.js create mode 100644 migrations/1719409568000-multiuser.js create mode 100644 src/accounts/openid.js create mode 100644 src/accounts/password.js create mode 100644 src/app-admin.js create mode 100644 src/app-admin.test.js create mode 100644 src/app-openid.js create mode 100644 src/services/user-service.js create mode 100644 upcoming-release-notes/498.md diff --git a/jest.global-setup.js b/jest.global-setup.js index 36f53cf1c..524054dd5 100644 --- a/jest.global-setup.js +++ b/jest.global-setup.js @@ -1,10 +1,100 @@ import getAccountDb from './src/account-db.js'; import runMigrations from './src/migrations.js'; +const GENERIC_ADMIN_ID = 'genericAdmin'; +const GENERIC_USER_ID = 'genericUser'; +const ADMIN_ROLE_ID = 'ADMIN'; +const BASIC_ROLE_ID = 'BASIC'; + +const createUser = (userId, userName, role, owner = 0, enabled = 1) => { + const missingParams = []; + if (!userId) missingParams.push('userId'); + if (!userName) missingParams.push('userName'); + if (!role) missingParams.push('role'); + if (missingParams.length > 0) { + throw new Error(`Missing required parameters: ${missingParams.join(', ')}`); + } + + if ( + typeof userId !== 'string' || + typeof userName !== 'string' || + typeof role !== 'string' + ) { + throw new Error( + 'Invalid parameter types. userId, userName, and role must be strings', + ); + } + + try { + getAccountDb().mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', + [userId, userName, userName, enabled, owner, role], + ); + } catch (error) { + console.error(`Error creating user ${userName}:`, error); + throw error; + } +}; + +const setSessionUser = (userId, token = 'valid-token') => { + if (!userId) { + throw new Error('userId is required'); + } + + try { + const db = getAccountDb(); + const session = db.first('SELECT token FROM sessions WHERE token = ?', [ + token, + ]); + if (!session) { + throw new Error(`Session not found for token: ${token}`); + } + + db.mutate('UPDATE sessions SET user_id = ? WHERE token = ?', [ + userId, + token, + ]); + } catch (error) { + console.error(`Error updating session for user ${userId}:`, error); + throw error; + } +}; + export default async function setup() { + const NEVER_EXPIRES = -1; // or consider using a far future timestamp + await runMigrations(); + createUser(GENERIC_ADMIN_ID, 'admin', ADMIN_ROLE_ID, 1); + // Insert a fake "valid-token" fixture that can be reused const db = getAccountDb(); - await db.mutate('INSERT INTO sessions (token) VALUES (?)', ['valid-token']); + try { + await db.mutate('BEGIN TRANSACTION'); + + await db.mutate('DELETE FROM sessions'); + await db.mutate( + 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', + ['valid-token', NEVER_EXPIRES, 'genericAdmin'], + ); + await db.mutate( + 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', + ['valid-token-admin', NEVER_EXPIRES, 'genericAdmin'], + ); + + await db.mutate( + 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', + ['valid-token-user', NEVER_EXPIRES, 'genericUser'], + ); + + await db.mutate('COMMIT'); + } catch (error) { + await db.mutate('ROLLBACK'); + throw new Error(`Failed to setup test sessions: ${error.message}`); + } + + setSessionUser('genericAdmin'); + setSessionUser('genericAdmin', 'valid-token-admin'); + + createUser(GENERIC_USER_ID, 'user', BASIC_ROLE_ID, 1); } diff --git a/migrations/1718889148000-openid.js b/migrations/1718889148000-openid.js new file mode 100644 index 000000000..b170aea05 --- /dev/null +++ b/migrations/1718889148000-openid.js @@ -0,0 +1,41 @@ +import getAccountDb from '../src/account-db.js'; + +export const up = async function () { + await getAccountDb().exec( + ` + BEGIN TRANSACTION; + CREATE TABLE auth_new + (method TEXT PRIMARY KEY, + display_name TEXT, + extra_data TEXT, active INTEGER); + + INSERT INTO auth_new (method, display_name, extra_data, active) + SELECT 'password', 'Password', password, 1 FROM auth; + DROP TABLE auth; + ALTER TABLE auth_new RENAME TO auth; + + CREATE TABLE pending_openid_requests + (state TEXT PRIMARY KEY, + code_verifier TEXT, + return_url TEXT, + expiry_time INTEGER); + COMMIT;`, + ); +}; + +export const down = async function () { + await getAccountDb().exec( + ` + BEGIN TRANSACTION; + ALTER TABLE auth RENAME TO auth_temp; + CREATE TABLE auth + (password TEXT); + INSERT INTO auth (password) + SELECT extra_data FROM auth_temp WHERE method = 'password'; + DROP TABLE auth_temp; + + DROP TABLE pending_openid_requests; + COMMIT; + `, + ); +}; diff --git a/migrations/1719409568000-multiuser.js b/migrations/1719409568000-multiuser.js new file mode 100644 index 000000000..345da8ebd --- /dev/null +++ b/migrations/1719409568000-multiuser.js @@ -0,0 +1,104 @@ +import getAccountDb from '../src/account-db.js'; + +export const up = async function () { + await getAccountDb().exec( + ` + BEGIN TRANSACTION; + + CREATE TABLE users + (id TEXT PRIMARY KEY, + user_name TEXT, + display_name TEXT, + role TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + owner INTEGER NOT NULL DEFAULT 0); + + CREATE TABLE user_access + (user_id TEXT, + file_id TEXT, + PRIMARY KEY (user_id, file_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (file_id) REFERENCES files(id) + ); + + ALTER TABLE files + ADD COLUMN owner TEXT; + + DELETE FROM sessions; + + ALTER TABLE sessions + ADD COLUMN expires_at INTEGER; + + ALTER TABLE sessions + ADD COLUMN user_id TEXT; + + ALTER TABLE sessions + ADD COLUMN auth_method TEXT; + COMMIT; + `, + ); +}; + +export const down = async function () { + await getAccountDb().exec( + ` + BEGIN TRANSACTION; + + DROP TABLE IF EXISTS user_access; + + CREATE TABLE sessions_backup ( + token TEXT PRIMARY KEY + ); + + INSERT INTO sessions_backup (token) + SELECT token FROM sessions; + + DROP TABLE sessions; + + ALTER TABLE sessions_backup RENAME TO sessions; + + CREATE TABLE files_backup ( + id TEXT PRIMARY KEY, + group_id TEXT, + sync_version SMALLINT, + encrypt_meta TEXT, + encrypt_keyid TEXT, + encrypt_salt TEXT, + encrypt_test TEXT, + deleted BOOLEAN DEFAULT FALSE, + name TEXT + ); + + INSERT INTO files_backup ( + id, + group_id, + sync_version, + encrypt_meta, + encrypt_keyid, + encrypt_salt, + encrypt_test, + deleted, + name + ) + SELECT + id, + group_id, + sync_version, + encrypt_meta, + encrypt_keyid, + encrypt_salt, + encrypt_test, + deleted, + name + FROM files; + + DROP TABLE files; + + ALTER TABLE files_backup RENAME TO files; + + DROP TABLE IF EXISTS users; + + COMMIT; + `, + ); +}; diff --git a/package.json b/package.json index c402dfe4c..46e1018c6 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "jws": "^4.0.0", "migrate": "^2.0.1", "nordigen-node": "^1.4.0", + "openid-client": "^5.4.2", "uuid": "^9.0.0", "winston": "^3.14.2" }, diff --git a/src/account-db.js b/src/account-db.js index cc2fe5674..aa6678fd2 100644 --- a/src/account-db.js +++ b/src/account-db.js @@ -1,8 +1,9 @@ import { join } from 'node:path'; import openDatabase from './db.js'; import config from './load-config.js'; -import * as uuid from 'uuid'; import * as bcrypt from 'bcrypt'; +import { bootstrapPassword, loginWithPassword } from './accounts/password.js'; +import { bootstrapOpenId } from './accounts/openid.js'; let _accountDb; @@ -15,16 +16,29 @@ export default function getAccountDb() { 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 listLoginMethods() { + let accountDb = getAccountDb(); + let rows = accountDb.all('SELECT method, display_name, active FROM auth'); + return rows.map((r) => ({ + method: r.method, + active: r.active, + displayName: r.display_name, + })); +} + +export function getActiveLoginMethod() { + let accountDb = getAccountDb(); + let { method } = + accountDb.first('SELECT method FROM auth WHERE active = 1') || {}; + return method; +} + /* * Get the Login Method in the following order * req (the frontend can say which method in the case it wants to resort to forcing password auth) @@ -38,74 +52,154 @@ export function getLoginMethod(req) { ) { return req.body.loginMethod; } + return config.loginMethod || 'password'; } -export function bootstrap(password) { - if (password === undefined || password === '') { - return { error: 'invalid-password' }; +export async function bootstrap(loginSettings) { + if (!loginSettings) { + return { error: 'invalid-login-settings' }; } + const passEnabled = 'password' in loginSettings; + const openIdEnabled = 'openId' in loginSettings; + + const accountDb = getAccountDb(); + accountDb.mutate('BEGIN TRANSACTION'); + try { + const { countOfOwner } = + accountDb.first( + `SELECT count(*) as countOfOwner + FROM users + WHERE users.user_name <> '' and users.owner = 1`, + ) || {}; + + if (!openIdEnabled || countOfOwner > 0) { + if (!needsBootstrap()) { + accountDb.mutate('ROLLBACK'); + return { error: 'already-bootstrapped' }; + } + } + + if (!passEnabled && !openIdEnabled) { + accountDb.mutate('ROLLBACK'); + return { error: 'no-auth-method-selected' }; + } + + if (passEnabled && openIdEnabled) { + accountDb.mutate('ROLLBACK'); + return { error: 'max-one-method-allowed' }; + } + + if (passEnabled) { + let { error } = bootstrapPassword(loginSettings.password); + if (error) { + accountDb.mutate('ROLLBACK'); + return { error }; + } + } + + if (openIdEnabled) { + let { error } = await bootstrapOpenId(loginSettings.openId); + if (error) { + accountDb.mutate('ROLLBACK'); + return { error }; + } + } + + accountDb.mutate('COMMIT'); + return passEnabled ? loginWithPassword(loginSettings.password) : {}; + } catch (error) { + accountDb.mutate('ROLLBACK'); + throw error; + } +} - let accountDb = getAccountDb(); - let rows = accountDb.all('SELECT * FROM auth'); +export function isAdmin(userId) { + return hasPermission(userId, 'ADMIN'); +} - if (rows.length !== 0) { - return { error: 'already-bootstrapped' }; - } +export function hasPermission(userId, permission) { + return getUserPermission(userId) === permission; +} - // 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]); +export async function enableOpenID(loginSettings) { + if (!loginSettings || !loginSettings.openId) { + return { error: 'invalid-login-settings' }; + } - let token = uuid.v4(); - accountDb.mutate('INSERT INTO sessions (token) VALUES (?)', [token]); + let { error } = (await bootstrapOpenId(loginSettings.openId)) || {}; + if (error) { + return { error }; + } - return { token }; + getAccountDb().mutate('DELETE FROM sessions'); } -export function login(password) { - if (password === undefined || password === '') { - return { error: 'invalid-password' }; +export async function disableOpenID(loginSettings) { + if (!loginSettings || !loginSettings.password) { + return { error: 'invalid-login-settings' }; } let accountDb = getAccountDb(); - let row = accountDb.first('SELECT * FROM auth'); - - let confirmed = row && bcrypt.compareSync(password, row.password); + const { extra_data: passwordHash } = + accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [ + 'password', + ]) || {}; - if (!confirmed) { + if (!passwordHash) { return { error: 'invalid-password' }; } - // 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 sessionRow = accountDb.first('SELECT * FROM sessions'); - return { token: sessionRow.token }; -} - -export function changePassword(newPassword) { - if (newPassword === undefined || newPassword === '') { + if (!loginSettings?.password) { return { error: 'invalid-password' }; } - let accountDb = getAccountDb(); + if (passwordHash) { + let confirmed = bcrypt.compareSync(loginSettings.password, passwordHash); - let hashed = hashPassword(newPassword); - let token = uuid.v4(); + if (!confirmed) { + return { error: 'invalid-password' }; + } + } - // 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]); + let { error } = (await bootstrapPassword(loginSettings.password)) || {}; + if (error) { + return { error }; + } - return {}; + getAccountDb().mutate('DELETE FROM sessions'); + getAccountDb().mutate('DELETE FROM users WHERE user_name <> ?', ['']); + getAccountDb().mutate('DELETE FROM auth WHERE method = ?', ['openid']); } export function getSession(token) { let accountDb = getAccountDb(); return accountDb.first('SELECT * FROM sessions WHERE token = ?', [token]); } + +export function getUserInfo(userId) { + let accountDb = getAccountDb(); + return accountDb.first('SELECT * FROM users WHERE id = ?', [userId]); +} + +export function getUserPermission(userId) { + let accountDb = getAccountDb(); + const { role } = accountDb.first( + `SELECT role FROM users + WHERE users.id = ?`, + [userId], + ) || { role: '' }; + + return role; +} + +export function clearExpiredSessions() { + const clearThreshold = Math.floor(Date.now() / 1000) - 3600; + + const deletedSessions = getAccountDb().mutate( + 'DELETE FROM sessions WHERE expires_at <> -1 and expires_at < ?', + [clearThreshold], + ).changes; + + console.log(`Deleted ${deletedSessions} old sessions`); +} diff --git a/src/accounts/openid.js b/src/accounts/openid.js new file mode 100644 index 000000000..784a6e5b3 --- /dev/null +++ b/src/accounts/openid.js @@ -0,0 +1,316 @@ +import getAccountDb, { clearExpiredSessions } from '../account-db.js'; +import * as uuid from 'uuid'; +import { generators, Issuer } from 'openid-client'; +import finalConfig from '../load-config.js'; +import { TOKEN_EXPIRATION_NEVER } from '../util/validate-user.js'; +import { + getUserByUsername, + transferAllFilesFromUser, +} from '../services/user-service.js'; + +export async function bootstrapOpenId(config) { + if (!('issuer' in config)) { + return { error: 'missing-issuer' }; + } + if (!('client_id' in config)) { + return { error: 'missing-client-id' }; + } + if (!('client_secret' in config)) { + return { error: 'missing-client-secret' }; + } + if (!('server_hostname' in config)) { + return { error: 'missing-server-hostname' }; + } + + try { + await setupOpenIdClient(config); + } catch (err) { + console.error('Error setting up OpenID client:', err); + return { error: 'configuration-error' }; + } + + let accountDb = getAccountDb(); + try { + accountDb.transaction(() => { + accountDb.mutate('DELETE FROM auth WHERE method = ?', ['openid']); + accountDb.mutate('UPDATE auth SET active = 0'); + accountDb.mutate( + "INSERT INTO auth (method, display_name, extra_data, active) VALUES ('openid', 'OpenID', ?, 1)", + [JSON.stringify(config)], + ); + }); + } catch (err) { + console.error('Error updating auth table:', err); + return { error: 'database-error' }; + } + + return {}; +} + +async function setupOpenIdClient(config) { + let issuer = + typeof config.issuer === 'string' + ? await Issuer.discover(config.issuer) + : new Issuer({ + issuer: config.issuer.name, + authorization_endpoint: config.issuer.authorization_endpoint, + token_endpoint: config.issuer.token_endpoint, + userinfo_endpoint: config.issuer.userinfo_endpoint, + }); + + const client = new issuer.Client({ + client_id: config.client_id, + client_secret: config.client_secret, + redirect_uri: new URL( + '/openid/callback', + config.server_hostname, + ).toString(), + validate_id_token: true, + }); + + return client; +} + +export async function loginWithOpenIdSetup(returnUrl) { + if (!returnUrl) { + return { error: 'return-url-missing' }; + } + if (!isValidRedirectUrl(returnUrl)) { + return { error: 'invalid-return-url' }; + } + + let accountDb = getAccountDb(); + let config = accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [ + 'openid', + ]); + if (!config) { + return { error: 'openid-not-configured' }; + } + + try { + config = JSON.parse(config['extra_data']); + } catch (err) { + console.error('Error parsing OpenID configuration:', err); + return { error: 'openid-setup-failed' }; + } + + let client; + try { + client = await setupOpenIdClient(config); + } catch (err) { + console.error('Error setting up OpenID client:', err); + return { error: 'openid-setup-failed' }; + } + + const state = generators.state(); + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); + + const now_time = Date.now(); + const expiry_time = now_time + 300 * 1000; + + accountDb.mutate( + 'DELETE FROM pending_openid_requests WHERE expiry_time < ?', + [now_time], + ); + accountDb.mutate( + 'INSERT INTO pending_openid_requests (state, code_verifier, return_url, expiry_time) VALUES (?, ?, ?, ?)', + [state, code_verifier, returnUrl, expiry_time], + ); + + const url = client.authorizationUrl({ + response_type: 'code', + scope: 'openid email profile', + state, + code_challenge, + code_challenge_method: 'S256', + }); + + return { url }; +} + +export async function loginWithOpenIdFinalize(body) { + if (!body.code) { + return { error: 'missing-authorization-code' }; + } + if (!body.state) { + return { error: 'missing-state' }; + } + + let accountDb = getAccountDb(); + let config = accountDb.first( + "SELECT extra_data FROM auth WHERE method = 'openid' AND active = 1", + ); + if (!config) { + return { error: 'openid-not-configured' }; + } + try { + config = JSON.parse(config['extra_data']); + } catch (err) { + console.error('Error parsing OpenID configuration:', err); + return { error: 'openid-setup-failed' }; + } + let client; + try { + client = await setupOpenIdClient(config); + } catch (err) { + console.error('Error setting up OpenID client:', err); + return { error: 'openid-setup-failed' }; + } + + let pendingRequest = accountDb.first( + 'SELECT code_verifier, return_url FROM pending_openid_requests WHERE state = ? AND expiry_time > ?', + [body.state, Date.now()], + ); + + if (!pendingRequest) { + return { error: 'invalid-or-expired-state' }; + } + + let { code_verifier, return_url } = pendingRequest; + + try { + const params = { code: body.code, state: body.state }; + let tokenSet = await client.callback(client.redirect_uris[0], params, { + code_verifier, + state: body.state, + }); + const userInfo = await client.userinfo(tokenSet.access_token); + const identity = + userInfo.preferred_username ?? + userInfo.login ?? + userInfo.email ?? + userInfo.id ?? + userInfo.name ?? + 'default-username'; + if (identity == null) { + return { error: 'openid-grant-failed: no identification was found' }; + } + + let userId = null; + try { + accountDb.transaction(() => { + let { countUsersWithUserName } = accountDb.first( + 'SELECT count(*) as countUsersWithUserName FROM users WHERE user_name <> ?', + [''], + ); + if (countUsersWithUserName === 0) { + userId = uuid.v4(); + // Check if user was created by another transaction + const existingUser = accountDb.first( + 'SELECT id FROM users WHERE user_name = ?', + [identity], + ); + if (existingUser) { + throw new Error('user-already-exists'); + } + accountDb.mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, 1, 1, ?)', + [ + userId, + identity, + userInfo.name ?? userInfo.email ?? identity, + 'ADMIN', + ], + ); + + const userFromPasswordMethod = getUserByUsername(''); + if (userFromPasswordMethod) { + transferAllFilesFromUser(userId, userFromPasswordMethod.user_id); + } + } else { + let { id: userIdFromDb, display_name: displayName } = + accountDb.first( + 'SELECT id, display_name FROM users WHERE user_name = ? and enabled = 1', + [identity], + ) || {}; + + if (userIdFromDb == null) { + throw new Error('openid-grant-failed'); + } + + if (!displayName && userInfo.name) { + accountDb.mutate('UPDATE users set display_name = ? WHERE id = ?', [ + userInfo.name, + userIdFromDb, + ]); + } + + userId = userIdFromDb; + } + }); + } catch (error) { + if (error.message === 'user-already-exists') { + return { error: 'user-already-exists' }; + } else if (error.message === 'openid-grant-failed') { + return { error: 'openid-grant-failed' }; + } else { + throw error; // Re-throw other unexpected errors + } + } + + const token = uuid.v4(); + + let expiration; + if (finalConfig.token_expiration === 'openid-provider') { + expiration = tokenSet.expires_at ?? TOKEN_EXPIRATION_NEVER; + } else if (finalConfig.token_expiration === 'never') { + expiration = TOKEN_EXPIRATION_NEVER; + } else if (typeof finalConfig.token_expiration === 'number') { + expiration = + Math.floor(Date.now() / 1000) + finalConfig.token_expiration * 60; + } else { + expiration = Math.floor(Date.now() / 1000) + 10 * 60; + } + + accountDb.mutate( + 'INSERT INTO sessions (token, expires_at, user_id, auth_method) VALUES (?, ?, ?, ?)', + [token, expiration, userId, 'openid'], + ); + + clearExpiredSessions(); + + return { url: `${return_url}/openid-cb?token=${token}` }; + } catch (err) { + console.error('OpenID grant failed:', err); + return { error: 'openid-grant-failed' }; + } +} + +export function getServerHostname() { + const auth = getAccountDb().first( + 'select * from auth WHERE method = ? and active = 1', + ['openid'], + ); + if (auth && auth.extra_data) { + try { + const openIdConfig = JSON.parse(auth.extra_data); + return openIdConfig.server_hostname; + } catch (error) { + console.error('Error parsing OpenID configuration:', error); + } + } + return null; +} + +export function isValidRedirectUrl(url) { + const serverHostname = getServerHostname(); + + if (!serverHostname) { + return false; + } + + try { + const redirectUrl = new URL(url); + const serverUrl = new URL(serverHostname); + + // Compare origin (protocol + hostname + port) + if (redirectUrl.origin === serverUrl.origin) { + return true; + } else { + return false; + } + } catch (err) { + return false; + } +} diff --git a/src/accounts/password.js b/src/accounts/password.js new file mode 100644 index 000000000..e841697a7 --- /dev/null +++ b/src/accounts/password.js @@ -0,0 +1,124 @@ +import * as bcrypt from 'bcrypt'; +import getAccountDb, { clearExpiredSessions } from '../account-db.js'; +import * as uuid from 'uuid'; +import finalConfig from '../load-config.js'; +import { TOKEN_EXPIRATION_NEVER } from '../util/validate-user.js'; + +function isValidPassword(password) { + return password != null && password !== ''; +} + +function hashPassword(password) { + return bcrypt.hashSync(password, 12); +} + +export function bootstrapPassword(password) { + if (!isValidPassword(password)) { + return { error: 'invalid-password' }; + } + + let hashed = hashPassword(password); + let accountDb = getAccountDb(); + accountDb.transaction(() => { + accountDb.mutate('DELETE FROM auth WHERE method = ?', ['password']); + accountDb.mutate('UPDATE auth SET active = 0'); + accountDb.mutate( + "INSERT INTO auth (method, display_name, extra_data, active) VALUES ('password', 'Password', ?, 1)", + [hashed], + ); + }); + + return {}; +} + +export function loginWithPassword(password) { + if (!isValidPassword(password)) { + return { error: 'invalid-password' }; + } + + let accountDb = getAccountDb(); + const { extra_data: passwordHash } = + accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [ + 'password', + ]) || {}; + + if (!passwordHash) { + return { error: 'invalid-password' }; + } + + let confirmed = bcrypt.compareSync(password, passwordHash); + + if (!confirmed) { + return { error: 'invalid-password' }; + } + + let sessionRow = accountDb.first( + 'SELECT * FROM sessions WHERE auth_method = ?', + ['password'], + ); + + let token = sessionRow ? sessionRow.token : uuid.v4(); + + let { totalOfUsers } = accountDb.first( + 'SELECT count(*) as totalOfUsers FROM users', + ); + let userId = null; + if (totalOfUsers === 0) { + userId = uuid.v4(); + accountDb.mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, 1, 1, ?)', + [userId, '', '', 'ADMIN'], + ); + } else { + let { id: userIdFromDb } = accountDb.first( + 'SELECT id FROM users WHERE user_name = ?', + [''], + ); + + userId = userIdFromDb; + + if (!userId) { + return { error: 'user-not-found' }; + } + } + + let expiration = TOKEN_EXPIRATION_NEVER; + if ( + finalConfig.token_expiration != 'never' && + finalConfig.token_expiration != 'openid-provider' && + typeof finalConfig.token_expiration === 'number' + ) { + expiration = + Math.floor(Date.now() / 1000) + finalConfig.token_expiration * 60; + } + + if (!sessionRow) { + accountDb.mutate( + 'INSERT INTO sessions (token, expires_at, user_id, auth_method) VALUES (?, ?, ?, ?)', + [token, expiration, userId, 'password'], + ); + } else { + accountDb.mutate( + 'UPDATE sessions SET user_id = ?, expires_at = ? WHERE token = ?', + [userId, expiration, token], + ); + } + + clearExpiredSessions(); + + return { token }; +} + +export function changePassword(newPassword) { + let accountDb = getAccountDb(); + + if (!isValidPassword(newPassword)) { + return { error: 'invalid-password' }; + } + + let hashed = hashPassword(newPassword); + accountDb.mutate("UPDATE auth SET extra_data = ? WHERE method = 'password'", [ + hashed, + ]); + return {}; +} diff --git a/src/app-account.js b/src/app-account.js index cc18ea32e..057c97d06 100644 --- a/src/app-account.js +++ b/src/app-account.js @@ -3,16 +3,21 @@ import { errorMiddleware, requestLoggerMiddleware, } from './util/middlewares.js'; -import validateUser, { validateAuthHeader } from './util/validate-user.js'; +import validateSession, { validateAuthHeader } from './util/validate-user.js'; import { bootstrap, - login, - changePassword, needsBootstrap, getLoginMethod, + listLoginMethods, + getUserInfo, + getActiveLoginMethod, } from './account-db.js'; +import { changePassword, loginWithPassword } from './accounts/password.js'; +import { isValidRedirectUrl, loginWithOpenIdSetup } from './accounts/openid.js'; let app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); app.use(errorMiddleware); app.use(requestLoggerMiddleware); export { app as handlers }; @@ -26,22 +31,30 @@ export { app as handlers }; app.get('/needs-bootstrap', (req, res) => { res.send({ status: 'ok', - data: { bootstrapped: !needsBootstrap(), loginMethod: getLoginMethod() }, + data: { + bootstrapped: !needsBootstrap(), + loginMethods: listLoginMethods(), + multiuser: getActiveLoginMethod() === 'openid', + }, }); }); -app.post('/bootstrap', (req, res) => { - let { error, token } = bootstrap(req.body.password); +app.post('/bootstrap', async (req, res) => { + let boot = await bootstrap(req.body); - if (error) { - res.status(400).send({ status: 'error', reason: error }); + if (boot?.error) { + res.status(400).send({ status: 'error', reason: boot?.error }); return; } + res.send({ status: 'ok', data: boot }); +}); - res.send({ status: 'ok', data: { token } }); +app.get('/login-methods', (req, res) => { + let methods = listLoginMethods(); + res.send({ status: 'ok', methods }); }); -app.post('/login', (req, res) => { +app.post('/login', async (req, res) => { let loginMethod = getLoginMethod(req); console.log('Logging in via ' + loginMethod); let tokenRes = null; @@ -56,7 +69,7 @@ app.post('/login', (req, res) => { return; } else { if (validateAuthHeader(req)) { - tokenRes = login(headerVal); + tokenRes = loginWithPassword(headerVal); } else { res.send({ status: 'error', reason: 'proxy-not-trusted' }); return; @@ -64,9 +77,25 @@ app.post('/login', (req, res) => { } break; } - case 'password': + case 'openid': { + if (!isValidRedirectUrl(req.body.return_url)) { + res + .status(400) + .send({ status: 'error', reason: 'Invalid redirect URL' }); + return; + } + + let { error, url } = await loginWithOpenIdSetup(req.body.return_url); + if (error) { + res.status(400).send({ status: 'error', reason: error }); + return; + } + res.send({ status: 'ok', data: { redirect_url: url } }); + return; + } + default: - tokenRes = login(req.body.password); + tokenRes = loginWithPassword(req.body.password); break; } let { error, token } = tokenRes; @@ -80,13 +109,13 @@ app.post('/login', (req, res) => { }); app.post('/change-password', (req, res) => { - let user = validateUser(req, res); - if (!user) return; + let session = validateSession(req, res); + if (!session) return; let { error } = changePassword(req.body.password); if (error) { - res.send({ status: 'error', reason: error }); + res.status(400).send({ status: 'error', reason: error }); return; } @@ -94,8 +123,24 @@ app.post('/change-password', (req, res) => { }); app.get('/validate', (req, res) => { - let user = validateUser(req, res); - if (user) { - res.send({ status: 'ok', data: { validated: true } }); + let session = validateSession(req, res); + if (session) { + const user = getUserInfo(session.user_id); + if (!user) { + res.status(400).send({ status: 'error', reason: 'User not found' }); + return; + } + + res.send({ + status: 'ok', + data: { + validated: true, + userName: user?.user_name, + permission: user?.role, + userId: session?.user_id, + displayName: user?.display_name, + loginMethod: session?.auth_method, + }, + }); } }); diff --git a/src/app-admin.js b/src/app-admin.js new file mode 100644 index 000000000..f920a266b --- /dev/null +++ b/src/app-admin.js @@ -0,0 +1,409 @@ +import express from 'express'; +import * as uuid from 'uuid'; +import { + errorMiddleware, + requestLoggerMiddleware, + validateSessionMiddleware, +} from './util/middlewares.js'; +import validateSession from './util/validate-user.js'; +import { isAdmin } from './account-db.js'; +import * as UserService from './services/user-service.js'; + +let app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(requestLoggerMiddleware); + +export { app as handlers }; + +app.get('/owner-created/', (req, res) => { + try { + const ownerCount = UserService.getOwnerCount(); + res.json(ownerCount > 0); + } catch (error) { + res.status(500).json({ error: 'Failed to retrieve owner count' }); + } +}); + +app.get('/users/', validateSessionMiddleware, (req, res) => { + const users = UserService.getAllUsers(); + res.json( + users.map((u) => ({ + ...u, + owner: u.owner === 1, + enabled: u.enabled === 1, + })), + ); +}); + +app.post('/users', validateSessionMiddleware, async (req, res) => { + if (!isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return; + } + + const { userName, role, displayName, enabled } = req.body; + + if (!userName || !role) { + res.status(400).send({ + status: 'error', + reason: `${!userName ? 'user-cant-be-empty' : 'role-cant-be-empty'}`, + details: `${!userName ? 'Username' : 'Role'} cannot be empty`, + }); + return; + } + + const roleIdFromDb = UserService.validateRole(role); + if (!roleIdFromDb) { + res.status(400).send({ + status: 'error', + reason: 'role-does-not-exists', + details: 'Selected role does not exist', + }); + return; + } + + const userIdInDb = UserService.getUserByUsername(userName); + if (userIdInDb) { + res.status(400).send({ + status: 'error', + reason: 'user-already-exists', + details: `User ${userName} already exists`, + }); + return; + } + + const userId = uuid.v4(); + UserService.insertUser( + userId, + userName, + displayName || null, + enabled ? 1 : 0, + ); + + res.status(200).send({ status: 'ok', data: { id: userId } }); +}); + +app.patch('/users', validateSessionMiddleware, async (req, res) => { + if (!isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return; + } + + const { id, userName, role, displayName, enabled } = req.body; + + if (!userName || !role) { + res.status(400).send({ + status: 'error', + reason: `${!userName ? 'user-cant-be-empty' : 'role-cant-be-empty'}`, + details: `${!userName ? 'Username' : 'Role'} cannot be empty`, + }); + return; + } + + const roleIdFromDb = UserService.validateRole(role); + if (!roleIdFromDb) { + res.status(400).send({ + status: 'error', + reason: 'role-does-not-exists', + details: 'Selected role does not exist', + }); + return; + } + + const userIdInDb = UserService.getUserById(id); + if (!userIdInDb) { + res.status(400).send({ + status: 'error', + reason: 'cannot-find-user-to-update', + details: `Cannot find user ${userName} to update`, + }); + return; + } + + UserService.updateUserWithRole( + userIdInDb, + userName, + displayName || null, + enabled ? 1 : 0, + role, + ); + + res.status(200).send({ status: 'ok', data: { id: userIdInDb } }); +}); + +app.delete('/users', validateSessionMiddleware, async (req, res) => { + if (!isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return; + } + + const ids = req.body.ids; + let totalDeleted = 0; + ids.forEach((item) => { + const ownerId = UserService.getOwnerId(); + + if (item === ownerId) return; + + UserService.deleteUserAccess(item); + UserService.transferAllFilesFromUser(ownerId, item); + const usersDeleted = UserService.deleteUser(item); + totalDeleted += usersDeleted; + }); + + if (ids.length === totalDeleted) { + res + .status(200) + .send({ status: 'ok', data: { someDeletionsFailed: false } }); + } else { + res.status(400).send({ + status: 'error', + reason: 'not-all-deleted', + details: '', + }); + } +}); + +app.get('/access', validateSessionMiddleware, (req, res) => { + const fileId = req.query.fileId; + + const { granted } = UserService.checkFilePermission( + fileId, + res.locals.user_id, + ) || { + granted: 0, + }; + + if (granted === 0 && !isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return false; + } + + const fileIdInDb = UserService.getFileById(fileId); + if (!fileIdInDb) { + res.status(404).send({ + status: 'error', + reason: 'invalid-file-id', + details: 'File not found at server', + }); + return false; + } + + const accesses = UserService.getUserAccess( + fileId, + res.locals.user_id, + isAdmin(res.locals.user_id), + ); + + res.json(accesses); +}); + +app.post('/access', (req, res) => { + const userAccess = req.body || {}; + const session = validateSession(req, res); + + if (!session) return; + + const { granted } = UserService.checkFilePermission( + userAccess.fileId, + session.user_id, + ) || { + granted: 0, + }; + + if (granted === 0 && !isAdmin(session.user_id)) { + res.status(400).send({ + status: 'error', + reason: 'file-denied', + details: "You don't have permissions over this file", + }); + return; + } + + const fileIdInDb = UserService.getFileById(userAccess.fileId); + if (!fileIdInDb) { + res.status(404).send({ + status: 'error', + reason: 'invalid-file-id', + details: 'File not found at server', + }); + return; + } + + if (!userAccess.userId) { + res.status(400).send({ + status: 'error', + reason: 'user-cant-be-empty', + details: 'User cannot be empty', + }); + return; + } + + if (UserService.countUserAccess(userAccess.fileId, userAccess.userId) > 0) { + res.status(400).send({ + status: 'error', + reason: 'user-already-have-access', + details: 'User already have access', + }); + return; + } + + UserService.addUserAccess(userAccess.userId, userAccess.fileId); + + res.status(200).send({ status: 'ok', data: {} }); +}); + +app.delete('/access', (req, res) => { + const fileId = req.query.fileId; + const session = validateSession(req, res); + if (!session) return; + + const { granted } = UserService.checkFilePermission( + fileId, + session.user_id, + ) || { + granted: 0, + }; + + if (granted === 0 && !isAdmin(session.user_id)) { + res.status(400).send({ + status: 'error', + reason: 'file-denied', + details: "You don't have permissions over this file", + }); + return; + } + + const fileIdInDb = UserService.getFileById(fileId); + if (!fileIdInDb) { + res.status(404).send({ + status: 'error', + reason: 'invalid-file-id', + details: 'File not found at server', + }); + return; + } + + const ids = req.body.ids; + let totalDeleted = UserService.deleteUserAccessByFileId(ids, fileId); + + if (ids.length === totalDeleted) { + res + .status(200) + .send({ status: 'ok', data: { someDeletionsFailed: false } }); + } else { + res.status(400).send({ + status: 'error', + reason: 'not-all-deleted', + details: '', + }); + } +}); + +app.get('/access/users', validateSessionMiddleware, async (req, res) => { + const fileId = req.query.fileId; + + const { granted } = UserService.checkFilePermission( + fileId, + res.locals.user_id, + ) || { + granted: 0, + }; + + if (granted === 0 && !isAdmin(res.locals.user_id)) { + res.status(400).send({ + status: 'error', + reason: 'file-denied', + details: "You don't have permissions over this file", + }); + return; + } + + const fileIdInDb = UserService.getFileById(fileId); + if (!fileIdInDb) { + res.status(404).send({ + status: 'error', + reason: 'invalid-file-id', + details: 'File not found at server', + }); + return; + } + + const users = UserService.getAllUserAccess(fileId); + res.json(users); +}); + +app.post( + '/access/transfer-ownership/', + validateSessionMiddleware, + (req, res) => { + const newUserOwner = req.body || {}; + + const { granted } = UserService.checkFilePermission( + newUserOwner.fileId, + res.locals.user_id, + ) || { + granted: 0, + }; + + if (granted === 0 && !isAdmin(res.locals.user_id)) { + res.status(400).send({ + status: 'error', + reason: 'file-denied', + details: "You don't have permissions over this file", + }); + return; + } + + const fileIdInDb = UserService.getFileById(newUserOwner.fileId); + if (!fileIdInDb) { + res.status(404).send({ + status: 'error', + reason: 'invalid-file-id', + details: 'File not found at server', + }); + return; + } + + if (!newUserOwner.newUserId) { + res.status(400).send({ + status: 'error', + reason: 'user-cant-be-empty', + details: 'Username cannot be empty', + }); + return; + } + + const newUserIdFromDb = UserService.getUserById(newUserOwner.newUserId); + if (newUserIdFromDb === 0) { + res.status(400).send({ + status: 'error', + reason: 'new-user-not-found', + details: 'New user not found', + }); + return; + } + + UserService.updateFileOwner(newUserOwner.newUserId, newUserOwner.fileId); + + res.status(200).send({ status: 'ok', data: {} }); + }, +); + +app.use(errorMiddleware); diff --git a/src/app-admin.test.js b/src/app-admin.test.js new file mode 100644 index 000000000..29f22ec25 --- /dev/null +++ b/src/app-admin.test.js @@ -0,0 +1,380 @@ +import request from 'supertest'; +import { handlers as app } from './app-admin.js'; +import getAccountDb from './account-db.js'; +import { v4 as uuidv4 } from 'uuid'; + +const ADMIN_ROLE = 'ADMIN'; +const BASIC_ROLE = 'BASIC'; + +// Create user helper function +const createUser = (userId, userName, role, owner = 0, enabled = 1) => { + getAccountDb().mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', + [userId, userName, `${userName} display`, enabled, owner, role], + ); +}; + +const deleteUser = (userId) => { + getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [userId]); + getAccountDb().mutate('DELETE FROM users WHERE id = ?', [userId]); +}; + +const createSession = (userId, sessionToken) => { + getAccountDb().mutate( + 'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', + [sessionToken, userId, Date.now() + 1000 * 60 * 60], // Expire in 1 hour + ); +}; + +const generateSessionToken = () => `token-${uuidv4()}`; + +describe('/admin', () => { + describe('/owner-created', () => { + it('should return 200 and true if an owner user is created', async () => { + const sessionToken = generateSessionToken(); + const adminId = uuidv4(); + createUser(adminId, 'admin', ADMIN_ROLE, 1); + createSession(adminId, sessionToken); + + const res = await request(app) + .get('/owner-created') + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body).toBe(true); + }); + }); + + describe('/users', () => { + describe('GET /users', () => { + let sessionUserId, testUserId, sessionToken; + + beforeEach(() => { + sessionUserId = uuidv4(); + testUserId = uuidv4(); + sessionToken = generateSessionToken(); + + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + createUser(testUserId, 'testUser', ADMIN_ROLE); + }); + + afterEach(() => { + deleteUser(sessionUserId); + deleteUser(testUserId); + }); + + it('should return 200 and a list of users', async () => { + const res = await request(app) + .get('/users') + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.length).toBeGreaterThan(0); + }); + }); + + describe('POST /users', () => { + let sessionUserId, sessionToken; + let createdUserId; + let duplicateUserId; + + beforeEach(() => { + sessionUserId = uuidv4(); + sessionToken = generateSessionToken(); + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + }); + + afterEach(() => { + deleteUser(sessionUserId); + if (createdUserId) { + deleteUser(createdUserId); + createdUserId = null; + } + + if (duplicateUserId) { + deleteUser(duplicateUserId); + duplicateUserId = null; + } + }); + + it('should return 200 and create a new user', async () => { + const newUser = { + userName: 'user1', + displayName: 'User One', + enabled: 1, + owner: 0, + role: BASIC_ROLE, + }; + + const res = await request(app) + .post('/users') + .send(newUser) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.status).toBe('ok'); + expect(res.body.data).toHaveProperty('id'); + + createdUserId = res.body.data.id; + }); + + it('should return 400 if the user already exists', async () => { + const newUser = { + userName: 'user1', + displayName: 'User One', + enabled: 1, + owner: 0, + role: BASIC_ROLE, + }; + + let res = await request(app) + .post('/users') + .send(newUser) + .set('x-actual-token', sessionToken); + + duplicateUserId = res.body.data.id; + + res = await request(app) + .post('/users') + .send(newUser) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(400); + expect(res.body.status).toBe('error'); + expect(res.body.reason).toBe('user-already-exists'); + }); + }); + + describe('PATCH /users', () => { + let sessionUserId, testUserId, sessionToken; + + beforeEach(() => { + sessionUserId = uuidv4(); + testUserId = uuidv4(); + sessionToken = generateSessionToken(); + + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + createUser(testUserId, 'testUser', ADMIN_ROLE); + }); + + afterEach(() => { + deleteUser(sessionUserId); + deleteUser(testUserId); + }); + + it('should return 200 and update an existing user', async () => { + const userToUpdate = { + id: testUserId, + userName: 'updatedUser', + displayName: 'Updated User', + enabled: true, + role: BASIC_ROLE, + }; + + const res = await request(app) + .patch('/users') + .send(userToUpdate) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.status).toBe('ok'); + expect(res.body.data.id).toBe(testUserId); + }); + + it('should return 400 if the user does not exist', async () => { + const userToUpdate = { + id: 'non-existing-id', + userName: 'nonexistinguser', + displayName: 'Non-existing User', + enabled: true, + role: BASIC_ROLE, + }; + + const res = await request(app) + .patch('/users') + .send(userToUpdate) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(400); + expect(res.body.status).toBe('error'); + expect(res.body.reason).toBe('cannot-find-user-to-update'); + }); + }); + + describe('POST /users/delete-all', () => { + let sessionUserId, testUserId, sessionToken; + + beforeEach(() => { + sessionUserId = uuidv4(); + testUserId = uuidv4(); + sessionToken = generateSessionToken(); + + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + createUser(testUserId, 'testUser', ADMIN_ROLE); + }); + + afterEach(() => { + deleteUser(sessionUserId); + deleteUser(testUserId); + }); + + it('should return 200 and delete all specified users', async () => { + const userToDelete = { + ids: [testUserId], + }; + + const res = await request(app) + .delete('/users') + .send(userToDelete) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.status).toBe('ok'); + expect(res.body.data.someDeletionsFailed).toBe(false); + }); + + it('should return 400 if not all users are deleted', async () => { + const userToDelete = { + ids: ['non-existing-id'], + }; + + const res = await request(app) + .delete('/users') + .send(userToDelete) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(400); + expect(res.body.status).toBe('error'); + expect(res.body.reason).toBe('not-all-deleted'); + }); + }); + }); + + describe('/access', () => { + describe('POST /access', () => { + let sessionUserId, testUserId, fileId, sessionToken; + + beforeEach(() => { + sessionUserId = uuidv4(); + testUserId = uuidv4(); + fileId = uuidv4(); + sessionToken = generateSessionToken(); + + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + createUser(testUserId, 'testUser', ADMIN_ROLE); + getAccountDb().mutate('INSERT INTO files (id, owner) VALUES (?, ?)', [ + fileId, + sessionUserId, + ]); + }); + + afterEach(() => { + deleteUser(sessionUserId); + deleteUser(testUserId); + getAccountDb().mutate('DELETE FROM files WHERE id = ?', [fileId]); + }); + + it('should return 200 and grant access to a user', async () => { + const newUserAccess = { + fileId, + userId: testUserId, + }; + + const res = await request(app) + .post('/access') + .send(newUserAccess) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.status).toBe('ok'); + }); + + it('should return 400 if the user already has access', async () => { + const newUserAccess = { + fileId, + userId: testUserId, + }; + + await request(app) + .post('/access') + .send(newUserAccess) + .set('x-actual-token', sessionToken); + + const res = await request(app) + .post('/access') + .send(newUserAccess) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(400); + expect(res.body.status).toBe('error'); + expect(res.body.reason).toBe('user-already-have-access'); + }); + }); + + describe('DELETE /access', () => { + let sessionUserId, testUserId, fileId, sessionToken; + + beforeEach(() => { + sessionUserId = uuidv4(); + testUserId = uuidv4(); + fileId = uuidv4(); + sessionToken = generateSessionToken(); + + createUser(sessionUserId, 'sessionUser', ADMIN_ROLE); + createSession(sessionUserId, sessionToken); + createUser(testUserId, 'testUser', ADMIN_ROLE); + getAccountDb().mutate('INSERT INTO files (id, owner) VALUES (?, ?)', [ + fileId, + sessionUserId, + ]); + getAccountDb().mutate( + 'INSERT INTO user_access (user_id, file_id) VALUES (?, ?)', + [testUserId, fileId], + ); + }); + + afterEach(() => { + deleteUser(sessionUserId); + deleteUser(testUserId); + getAccountDb().mutate('DELETE FROM files WHERE id = ?', [fileId]); + }); + + it('should return 200 and delete access for the specified user', async () => { + const deleteAccess = { + ids: [testUserId], + }; + + const res = await request(app) + .delete('/access') + .send(deleteAccess) + .query({ fileId }) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(200); + expect(res.body.status).toBe('ok'); + expect(res.body.data.someDeletionsFailed).toBe(false); + }); + + it('should return 400 if not all access deletions are successful', async () => { + const deleteAccess = { + ids: ['non-existing-id'], + }; + + const res = await request(app) + .delete('/access') + .send(deleteAccess) + .query({ fileId }) + .set('x-actual-token', sessionToken); + + expect(res.statusCode).toEqual(400); + expect(res.body.status).toBe('error'); + expect(res.body.reason).toBe('not-all-deleted'); + }); + }); + }); +}); diff --git a/src/app-gocardless/app-gocardless.js b/src/app-gocardless/app-gocardless.js index cdba0dca6..cabeffcd6 100644 --- a/src/app-gocardless/app-gocardless.js +++ b/src/app-gocardless/app-gocardless.js @@ -14,7 +14,7 @@ import { handleError } from './util/handle-error.js'; import { sha256String } from '../util/hash.js'; import { requestLoggerMiddleware, - validateUserMiddleware, + validateSessionMiddleware, } from '../util/middlewares.js'; const app = express(); @@ -26,7 +26,7 @@ app.get('/link', function (req, res) { export { app as handlers }; app.use(express.json()); -app.use(validateUserMiddleware); +app.use(validateSessionMiddleware); app.post('/status', async (req, res) => { res.send({ diff --git a/src/app-openid.js b/src/app-openid.js new file mode 100644 index 000000000..931484074 --- /dev/null +++ b/src/app-openid.js @@ -0,0 +1,101 @@ +import express from 'express'; +import { + errorMiddleware, + requestLoggerMiddleware, + validateSessionMiddleware, +} from './util/middlewares.js'; +import { disableOpenID, enableOpenID, isAdmin } from './account-db.js'; +import { + isValidRedirectUrl, + loginWithOpenIdFinalize, +} from './accounts/openid.js'; +import * as UserService from './services/user-service.js'; + +let app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(requestLoggerMiddleware); +export { app as handlers }; + +app.post('/enable', validateSessionMiddleware, async (req, res) => { + if (!isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return; + } + + let { error } = (await enableOpenID(req.body)) || {}; + + if (error) { + res.status(500).send({ status: 'error', reason: error }); + return; + } + res.send({ status: 'ok' }); +}); + +app.post('/disable', validateSessionMiddleware, async (req, res) => { + if (!isAdmin(res.locals.user_id)) { + res.status(403).send({ + status: 'error', + reason: 'forbidden', + details: 'permission-not-found', + }); + return; + } + + let { error } = (await disableOpenID(req.body)) || {}; + + if (error) { + res.status(500).send({ status: 'error', reason: error }); + return; + } + res.send({ status: 'ok' }); +}); + +app.get('/config', async (req, res) => { + const { cnt: ownerCount } = UserService.getOwnerCount() || {}; + + if (ownerCount > 0) { + res.status(400).send({ status: 'error', reason: 'already-bootstraped' }); + return; + } + + const auth = UserService.getOpenIDConfig(); + + if (!auth) { + res + .status(500) + .send({ status: 'error', reason: 'OpenID configuration not found' }); + return; + } + + try { + const openIdConfig = JSON.parse(auth.extra_data); + res.send({ openId: openIdConfig }); + } catch (error) { + res + .status(500) + .send({ status: 'error', reason: 'Invalid OpenID configuration' }); + } +}); + +app.get('/callback', async (req, res) => { + let { error, url } = await loginWithOpenIdFinalize(req.query); + + if (error) { + res.status(400).send({ status: 'error', reason: error }); + return; + } + + if (!isValidRedirectUrl(url)) { + res.status(400).send({ status: 'error', reason: 'Invalid redirect URL' }); + return; + } + + res.redirect(url); +}); + +app.use(errorMiddleware); diff --git a/src/app-secrets.js b/src/app-secrets.js index 3f842d8cb..3c06bf8a0 100644 --- a/src/app-secrets.js +++ b/src/app-secrets.js @@ -1,8 +1,9 @@ import express from 'express'; import { secretsService } from './services/secrets-service.js'; +import getAccountDb, { isAdmin } from './account-db.js'; import { requestLoggerMiddleware, - validateUserMiddleware, + validateSessionMiddleware, } from './util/middlewares.js'; const app = express(); @@ -10,12 +11,39 @@ const app = express(); export { app as handlers }; app.use(express.json()); app.use(requestLoggerMiddleware); - -app.use(validateUserMiddleware); +app.use(validateSessionMiddleware); app.post('/', async (req, res) => { + let method; + try { + const result = getAccountDb().first( + 'SELECT method FROM auth WHERE active = 1', + ); + method = result?.method; + } catch (error) { + console.error('Failed to fetch auth method:', error); + return res.status(500).send({ + status: 'error', + reason: 'database-error', + details: 'Failed to validate authentication method', + }); + } const { name, value } = req.body; + if (method === 'openid') { + let canSaveSecrets = isAdmin(res.locals.user_id); + + if (!canSaveSecrets) { + res.status(403).send({ + status: 'error', + reason: 'not-admin', + details: 'You have to be admin to set secrets', + }); + + return; + } + } + secretsService.set(name, value); res.status(200).send({ status: 'ok' }); diff --git a/src/app-sync.js b/src/app-sync.js index 793032166..92fdf3013 100644 --- a/src/app-sync.js +++ b/src/app-sync.js @@ -5,14 +5,14 @@ import * as uuid from 'uuid'; import { errorMiddleware, requestLoggerMiddleware, - validateUserMiddleware, + validateSessionMiddleware, } from './util/middlewares.js'; -import getAccountDb from './account-db.js'; import { getPathForUserFile, getPathForGroupFile } from './util/paths.js'; import * as simpleSync from './sync-simple.js'; import { SyncProtoBuf } from '@actual-app/crdt'; +import getAccountDb from './account-db.js'; import { File, FilesService, @@ -25,13 +25,13 @@ import { } from './app-sync/validation.js'; const app = express(); +app.use(validateSessionMiddleware); app.use(errorMiddleware); app.use(requestLoggerMiddleware); app.use(express.raw({ type: 'application/actual-sync' })); app.use(express.raw({ type: 'application/encrypted-file' })); app.use(express.json()); -app.use(validateUserMiddleware); export { app as handlers }; const OK_RESPONSE = { status: 'ok' }; @@ -113,6 +113,8 @@ app.post('/sync', async (req, res) => { }); app.post('/user-get-key', (req, res) => { + if (!res.locals) return; + let { fileId } = req.body; const filesService = new FilesService(getAccountDb()); @@ -246,6 +248,11 @@ app.post('/upload-user-file', async (req, res) => { syncVersion: syncFormatVersion, name: name, encryptMeta: encryptMeta, + owner: + res.locals.user_id || + (() => { + throw new Error('User ID is required for file creation'); + })(), }), ); @@ -305,7 +312,7 @@ app.post('/update-user-filename', (req, res) => { app.get('/list-user-files', (req, res) => { const fileService = new FilesService(getAccountDb()); - const rows = fileService.find(); + const rows = fileService.find({ userId: res.locals.user_id }); res.send({ status: 'ok', data: rows.map((row) => ({ @@ -314,6 +321,13 @@ app.get('/list-user-files', (req, res) => { groupId: row.groupId, name: row.name, encryptKeyId: row.encryptKeyId, + owner: row.owner, + usersWithAccess: fileService + .findUsersWithAccess(row.id) + .map((access) => ({ + ...access, + owner: access.userId === row.owner, + })), })), }); }); @@ -349,6 +363,12 @@ app.get('/get-user-file-info', (req, res) => { groupId: file.groupId, name: file.name, encryptMeta: file.encryptMeta ? JSON.parse(file.encryptMeta) : null, + usersWithAccess: fileService + .findUsersWithAccess(file.id) + .map((access) => ({ + ...access, + owner: access.userId === file.owner, + })), }, }); }); diff --git a/src/app-sync.test.js b/src/app-sync.test.js index b81d5f144..3d3dd10bd 100644 --- a/src/app-sync.test.js +++ b/src/app-sync.test.js @@ -1,11 +1,20 @@ import fs from 'node:fs'; import request from 'supertest'; import { handlers as app } from './app-sync.js'; -import getAccountDb from './account-db.js'; import { getPathForUserFile } from './util/paths.js'; +import getAccountDb from './account-db.js'; import { SyncProtoBuf } from '@actual-app/crdt'; import crypto from 'node:crypto'; +const ADMIN_ROLE = 'ADMIN'; + +const createUser = (userId, userName, role, owner = 0, enabled = 1) => { + getAccountDb().mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', + [userId, userName, `${userName} display`, enabled, owner, role], + ); +}; + describe('/user-get-key', () => { it('returns 401 if the user is not authenticated', async () => { const res = await request(app).post('/user-get-key'); @@ -25,8 +34,8 @@ describe('/user-get-key', () => { const encrypt_test = 'test-encrypt-test'; getAccountDb().mutate( - 'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test) VALUES (?, ?, ?, ?)', - [fileId, encrypt_salt, encrypt_keyid, encrypt_test], + 'INSERT INTO files (id, encrypt_salt, encrypt_keyid, encrypt_test, owner) VALUES (?, ?, ?, ?, ?)', + [fileId, encrypt_salt, encrypt_keyid, encrypt_test, 'genericAdmin'], ); const res = await request(app) @@ -135,8 +144,13 @@ describe('/reset-user-file', () => { // Use addMockFile to insert a mock file into the database getAccountDb().mutate( - 'INSERT INTO files (id, group_id, deleted) VALUES (?, ?, FALSE)', - [fileId, groupId], + 'INSERT INTO files (id, group_id, deleted, owner) VALUES (?, ?, FALSE, ?)', + [fileId, groupId, 'genericAdmin'], + ); + + getAccountDb().mutate( + 'INSERT INTO user_access (file_id, user_id) VALUES (?, ?)', + [fileId, 'genericAdmin'], ); const res = await request(app) @@ -518,6 +532,7 @@ describe('/list-user-files', () => { }); it('returns a list of user files for an authenticated user', async () => { + createUser('fileListAdminId', 'admin', ADMIN_ROLE, 1); const fileId1 = crypto.randomBytes(16).toString('hex'); const fileId2 = crypto.randomBytes(16).toString('hex'); const fileName1 = 'file1.txt'; @@ -525,12 +540,12 @@ describe('/list-user-files', () => { // Insert mock files into the database getAccountDb().mutate( - 'INSERT INTO files (id, name, deleted) VALUES (?, ?, FALSE)', - [fileId1, fileName1], + 'INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', + [fileId1, fileName1, ''], ); getAccountDb().mutate( - 'INSERT INTO files (id, name, deleted) VALUES (?, ?, FALSE)', - [fileId2, fileName2], + 'INSERT INTO files (id, name, deleted, owner) VALUES (?, ?, FALSE, ?)', + [fileId2, fileName2, ''], ); const res = await request(app) @@ -601,6 +616,7 @@ describe('/get-user-file-info', () => { groupId: fileInfo.group_id, name: fileInfo.name, encryptMeta: { key: 'value' }, + usersWithAccess: [], }, }); }); @@ -830,8 +846,8 @@ describe('/sync', () => { function addMockFile(fileId, groupId, keyId, encryptMeta, syncVersion) { getAccountDb().mutate( - 'INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version) VALUES (?, ?, ?,?, ?)', - [fileId, groupId, keyId, encryptMeta, syncVersion], + 'INSERT INTO files (id, group_id, encrypt_keyid, encrypt_meta, sync_version, owner) VALUES (?, ?, ?,?, ?, ?)', + [fileId, groupId, keyId, encryptMeta, syncVersion, 'genericAdmin'], ); } diff --git a/src/app-sync/services/files-service.js b/src/app-sync/services/files-service.js index 63fe7f373..a01ba4172 100644 --- a/src/app-sync/services/files-service.js +++ b/src/app-sync/services/files-service.js @@ -1,4 +1,4 @@ -import getAccountDb from '../../account-db.js'; +import getAccountDb, { isAdmin } from '../../account-db.js'; import { FileNotFound, GenericFileError } from '../errors.js'; class FileBase { @@ -11,6 +11,7 @@ class FileBase { encryptMeta, syncVersion, deleted, + owner, ) { this.name = name; this.groupId = groupId; @@ -20,6 +21,7 @@ class FileBase { this.encryptMeta = encryptMeta; this.syncVersion = syncVersion; this.deleted = typeof deleted === 'boolean' ? deleted : Boolean(deleted); + this.owner = owner; } } @@ -34,6 +36,7 @@ class File extends FileBase { encryptMeta = null, syncVersion = null, deleted = false, + owner = null, }) { super( name, @@ -44,6 +47,7 @@ class File extends FileBase { encryptMeta, syncVersion, deleted, + owner, ); this.id = id; } @@ -64,6 +68,7 @@ class FileUpdate extends FileBase { encryptMeta = undefined, syncVersion = undefined, deleted = undefined, + owner = undefined, }) { super( name, @@ -74,6 +79,7 @@ class FileUpdate extends FileBase { encryptMeta, syncVersion, deleted, + owner, ); } } @@ -99,7 +105,7 @@ class FilesService { set(file) { const deletedInt = boolToInt(file.deleted); this.accountDb.mutate( - 'INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_salt, encrypt_test, encrypt_keyid, deleted) VALUES (?, ?, ?, ?, ?, ?, ?,? ,?)', + 'INSERT INTO files (id, group_id, sync_version, name, encrypt_meta, encrypt_salt, encrypt_test, encrypt_keyid, deleted, owner) VALUES (?, ?, ?, ?, ?, ?, ?, ? ,?, ?)', [ file.id, file.groupId, @@ -110,14 +116,53 @@ class FilesService { file.encrypt_test, file.encrypt_keyid, deletedInt, + file.owner, ], ); } - find(limit = 1000) { - return this.accountDb - .all('SELECT * FROM files WHERE deleted = 0 LIMIT ?', [limit]) - .map(this.validate); + find({ userId, limit = 1000 }) { + const canSeeAll = isAdmin(userId); + + return ( + canSeeAll + ? this.accountDb.all('SELECT * FROM files WHERE deleted = 0 LIMIT ?', [ + limit, + ]) + : this.accountDb.all( + `SELECT files.* + FROM files + WHERE files.owner = ? and deleted = 0 + UNION + SELECT files.* + FROM files + JOIN user_access + ON user_access.file_id = files.id + AND user_access.user_id = ? + WHERE files.deleted = 0 LIMIT ?`, + [userId, userId, limit], + ) + ).map(this.validate); + } + + findUsersWithAccess(fileId) { + const userAccess = + this.accountDb.all( + `SELECT UA.user_id as userId, users.display_name displayName, users.user_name userName + FROM files + JOIN user_access UA ON UA.file_id = files.id + JOIN users on users.id = UA.user_id + WHERE files.id = ? + UNION ALL + SELECT users.id, users.display_name, users.user_name + FROM files + JOIN users on users.id = files.owner + WHERE files.id = ? + `, + [fileId, fileId], + ) || []; + + return userAccess; } update(id, fileUpdate) { @@ -188,6 +233,7 @@ class FilesService { encryptMeta: rawFile.encrypt_meta, syncVersion: rawFile.sync_version, deleted: Boolean(rawFile.deleted), + owner: rawFile.owner, }); } } diff --git a/src/app-sync/tests/services/files-service.test.js b/src/app-sync/tests/services/files-service.test.js index 7d3dadd6d..9807d99a0 100644 --- a/src/app-sync/tests/services/files-service.test.js +++ b/src/app-sync/tests/services/files-service.test.js @@ -28,6 +28,7 @@ describe('FilesService', () => { }; const clearDatabase = () => { + accountDb.mutate('DELETE FROM user_access'); accountDb.mutate('DELETE FROM files'); }; @@ -123,7 +124,7 @@ describe('FilesService', () => { ); test('find should return a list of files', () => { - const files = filesService.find(); + const files = filesService.find({ userId: 'genericAdmin' }); expect(files.length).toBe(1); expect(files[0]).toEqual( new File({ @@ -152,11 +153,14 @@ describe('FilesService', () => { }), ); // Make sure that the file was inserted - const allFiles = filesService.find(); + const allFiles = filesService.find({ userId: 'genericAdmin' }); expect(allFiles.length).toBe(2); // Limit the number of files returned - const limitedFiles = filesService.find(1); + const limitedFiles = filesService.find({ + userId: 'genericAdmin', + limit: 1, + }); expect(limitedFiles.length).toBe(1); }); @@ -188,6 +192,37 @@ describe('FilesService', () => { ); }); + test('find should return only files accessible to the user', () => { + filesService.set( + new File({ + id: crypto.randomBytes(16).toString('hex'), + groupId: 'group2', + syncVersion: 1, + name: 'file2', + encryptMeta: '{"key":"value2"}', + deleted: false, + owner: 'genericAdmin', + }), + ); + + filesService.set( + new File({ + id: crypto.randomBytes(16).toString('hex'), + groupId: 'group2', + syncVersion: 1, + name: 'file2', + encryptMeta: '{"key":"value2"}', + deleted: false, + owner: 'genericUser', + }), + ); + + expect(filesService.find({ userId: 'genericUser' })).toHaveLength(1); + expect( + filesService.find({ userId: 'genericAdmin' }).length, + ).toBeGreaterThan(1); + }); + test.each([['update-group', null]])( 'update should modify a single attribute with groupId = $groupId', (newGroupId) => { diff --git a/src/app.js b/src/app.js index 6dbf3506d..80504f14d 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,8 @@ import * as syncApp from './app-sync.js'; import * as goCardlessApp from './app-gocardless/app-gocardless.js'; import * as simpleFinApp from './app-simplefin/app-simplefin.js'; import * as secretApp from './app-secrets.js'; +import * as adminApp from './app-admin.js'; +import * as openidApp from './app-openid.js'; const app = express(); @@ -48,6 +50,9 @@ app.use('/gocardless', goCardlessApp.handlers); app.use('/simplefin', simpleFinApp.handlers); app.use('/secret', secretApp.handlers); +app.use('/admin', adminApp.handlers); +app.use('/openid', openidApp.handlers); + app.get('/mode', (req, res) => { res.send(config.mode); }); @@ -83,5 +88,6 @@ export default async function run() { } else { app.listen(config.port, config.hostname); } + console.log('Listening on ' + config.hostname + ':' + config.port + '...'); } diff --git a/src/config-types.ts b/src/config-types.ts index 8be7ba49d..778982d59 100644 --- a/src/config-types.ts +++ b/src/config-types.ts @@ -2,7 +2,7 @@ import { ServerOptions } from 'https'; export interface Config { mode: 'test' | 'development'; - loginMethod: 'password' | 'header'; + loginMethod: 'password' | 'header' | 'openid'; trustedProxies: string[]; dataDir: string; projectRoot: string; @@ -20,4 +20,19 @@ export interface Config { syncEncryptedFileSizeLimitMB: number; fileSizeLimitMB: number; }; + openId?: { + issuer: + | string + | { + name: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + }; + client_id: string; + client_secret: string; + server_hostname: string; + }; + multiuser: boolean; + token_expiration?: 'never' | 'openid-provider' | number; } diff --git a/src/load-config.js b/src/load-config.js index 19696d59d..9c8ee34f9 100644 --- a/src/load-config.js +++ b/src/load-config.js @@ -77,6 +77,8 @@ let defaultConfig = { fileSizeLimitMB: 20, }, projectRoot, + multiuser: false, + token_expiration: 'never', }; /** @type {import('./config-types.js').Config} */ @@ -105,6 +107,15 @@ const finalConfig = { loginMethod: process.env.ACTUAL_LOGIN_METHOD ? process.env.ACTUAL_LOGIN_METHOD.toLowerCase() : config.loginMethod, + multiuser: process.env.ACTUAL_MULTIUSER + ? (() => { + const value = process.env.ACTUAL_MULTIUSER.toLowerCase(); + if (!['true', 'false'].includes(value)) { + throw new Error('ACTUAL_MULTIUSER must be either "true" or "false"'); + } + return value === 'true'; + })() + : config.multiuser, trustedProxies: process.env.ACTUAL_TRUSTED_PROXIES ? process.env.ACTUAL_TRUSTED_PROXIES.split(',').map((q) => q.trim()) : config.trustedProxies, @@ -139,6 +150,55 @@ const finalConfig = { config.upload.fileSizeLimitMB, } : config.upload, + openId: (() => { + if ( + !process.env.ACTUAL_OPENID_DISCOVERY_URL && + !process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT + ) { + return config.openId; + } + const baseConfig = process.env.ACTUAL_OPENID_DISCOVERY_URL + ? { issuer: process.env.ACTUAL_OPENID_DISCOVERY_URL } + : { + ...(() => { + const required = { + authorization_endpoint: + process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT, + token_endpoint: process.env.ACTUAL_OPENID_TOKEN_ENDPOINT, + userinfo_endpoint: process.env.ACTUAL_OPENID_USERINFO_ENDPOINT, + }; + const missing = Object.entries(required) + .filter(([_, value]) => !value) + .map(([key]) => key); + if (missing.length > 0) { + throw new Error( + `Missing required OpenID configuration: ${missing.join(', ')}`, + ); + } + return {}; + })(), + issuer: { + name: process.env.ACTUAL_OPENID_PROVIDER_NAME, + authorization_endpoint: + process.env.ACTUAL_OPENID_AUTHORIZATION_ENDPOINT, + token_endpoint: process.env.ACTUAL_OPENID_TOKEN_ENDPOINT, + userinfo_endpoint: process.env.ACTUAL_OPENID_USERINFO_ENDPOINT, + }, + }; + return { + ...baseConfig, + client_id: + process.env.ACTUAL_OPENID_CLIENT_ID ?? config.openId?.client_id, + client_secret: + process.env.ACTUAL_OPENID_CLIENT_SECRET ?? config.openId?.client_secret, + server_hostname: + process.env.ACTUAL_OPENID_SERVER_HOSTNAME ?? + config.openId?.server_hostname, + }; + })(), + token_expiration: process.env.ACTUAL_TOKEN_EXPIRATION + ? process.env.ACTUAL_TOKEN_EXPIRATION + : config.token_expiration, }; debug(`using port ${finalConfig.port}`); debug(`using hostname ${finalConfig.hostname}`); diff --git a/src/scripts/reset-password.js b/src/scripts/reset-password.js index 26d5b1638..142269a9f 100644 --- a/src/scripts/reset-password.js +++ b/src/scripts/reset-password.js @@ -1,4 +1,5 @@ -import { needsBootstrap, bootstrap, changePassword } from '../account-db.js'; +import { bootstrap, needsBootstrap } from '../account-db.js'; +import { changePassword } from '../accounts/password.js'; import { promptPassword } from '../util/prompt.js'; if (needsBootstrap()) { @@ -6,31 +7,45 @@ if (needsBootstrap()) { 'It looks like you don’t have a password set yet. Let’s set one up now!', ); - promptPassword().then((password) => { - let { error } = bootstrap(password); + try { + const password = await promptPassword(); + const { error } = await 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!'); + process.exit(1); } - }); + console.log('Password set!'); + } catch (err) { + console.log('Unexpected error:', err); + console.log( + 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', + ); + process.exit(1); + } } else { console.log('It looks like you already have a password set. Let’s reset it!'); - promptPassword().then((password) => { - let { error } = changePassword(password); + try { + const password = await promptPassword(); + const { error } = await 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.', - ); + process.exit(1); } - }); + 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.', + ); + } catch (err) { + console.log('Unexpected error:', err); + console.log( + 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', + ); + process.exit(1); + } } diff --git a/src/services/user-service.js b/src/services/user-service.js new file mode 100644 index 000000000..ee353780c --- /dev/null +++ b/src/services/user-service.js @@ -0,0 +1,261 @@ +import getAccountDb from '../account-db.js'; + +export function getUserByUsername(userName) { + if (!userName || typeof userName !== 'string') { + return null; + } + const { id } = + getAccountDb().first('SELECT id FROM users WHERE user_name = ?', [ + userName, + ]) || {}; + return id || null; +} + +export function getUserById(userId) { + if (!userId) { + return null; + } + const { id } = + getAccountDb().first('SELECT * FROM users WHERE id = ?', [userId]) || {}; + return id || null; +} + +export function getFileById(fileId) { + if (!fileId) { + return null; + } + const { id } = + getAccountDb().first('SELECT * FROM files WHERE files.id = ?', [fileId]) || + {}; + return id || null; +} + +export function validateRole(roleId) { + const possibleRoles = ['BASIC', 'ADMIN']; + return possibleRoles.some((a) => a === roleId); +} + +export function getOwnerCount() { + const { ownerCount } = getAccountDb().first( + `SELECT count(*) as ownerCount FROM users WHERE users.user_name <> '' and users.owner = 1`, + ) || { ownerCount: 0 }; + return ownerCount; +} + +export function getOwnerId() { + const { id } = + getAccountDb().first( + `SELECT users.id FROM users WHERE users.user_name <> '' and users.owner = 1`, + ) || {}; + return id; +} + +export function getFileOwnerId(fileId) { + const { owner } = + getAccountDb().first(`SELECT files.owner FROM files WHERE files.id = ?`, [ + fileId, + ]) || {}; + return owner; +} + +export function getAllUsers() { + return getAccountDb().all( + `SELECT users.id, user_name as userName, display_name as displayName, enabled, ifnull(owner,0) as owner, role + FROM users + WHERE users.user_name <> ''`, + ); +} + +export function insertUser(userId, userName, displayName, enabled, role) { + getAccountDb().mutate( + 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, 0, ?)', + [userId, userName, displayName, enabled, role], + ); +} + +export function updateUser(userId, userName, displayName, enabled) { + if (!userId || !userName) { + throw new Error('Invalid user parameters'); + } + try { + getAccountDb().mutate( + 'UPDATE users SET user_name = ?, display_name = ?, enabled = ? WHERE id = ?', + [userName, displayName, enabled, userId], + ); + } catch (error) { + throw new Error(`Failed to update user: ${error.message}`); + } +} + +export function updateUserWithRole( + userId, + userName, + displayName, + enabled, + roleId, +) { + getAccountDb().transaction(() => { + getAccountDb().mutate( + 'UPDATE users SET user_name = ?, display_name = ?, enabled = ?, role = ? WHERE id = ?', + [userName, displayName, enabled, roleId, userId], + ); + }); +} + +export function deleteUser(userId) { + return getAccountDb().mutate('DELETE FROM users WHERE id = ? and owner = 0', [ + userId, + ]).changes; +} +export function deleteUserAccess(userId) { + try { + return getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [ + userId, + ]).changes; + } catch (error) { + throw new Error(`Failed to delete user access: ${error.message}`); + } +} + +export function transferAllFilesFromUser(ownerId, oldUserId) { + if (!ownerId || !oldUserId) { + throw new Error('Invalid user IDs'); + } + try { + getAccountDb().transaction(() => { + const ownerExists = getUserById(ownerId); + if (!ownerExists) { + throw new Error('New owner not found'); + } + getAccountDb().mutate('UPDATE files set owner = ? WHERE owner = ?', [ + ownerId, + oldUserId, + ]); + }); + } catch (error) { + throw new Error(`Failed to transfer files: ${error.message}`); + } +} + +export function updateFileOwner(ownerId, fileId) { + if (!ownerId || !fileId) { + throw new Error('Invalid parameters'); + } + try { + const result = getAccountDb().mutate( + 'UPDATE files set owner = ? WHERE id = ?', + [ownerId, fileId], + ); + if (result.changes === 0) { + throw new Error('File not found'); + } + } catch (error) { + throw new Error(`Failed to update file owner: ${error.message}`); + } +} + +export function getUserAccess(fileId, userId, isAdmin) { + return getAccountDb().all( + `SELECT users.id as userId, user_name as userName, files.owner, display_name as displayName + FROM users + JOIN user_access ON user_access.user_id = users.id + JOIN files ON files.id = user_access.file_id + WHERE files.id = ? and (files.owner = ? OR 1 = ?)`, + [fileId, userId, isAdmin ? 1 : 0], + ); +} + +export function countUserAccess(fileId, userId) { + const { accessCount } = + getAccountDb().first( + `SELECT COUNT(*) as accessCount + FROM files + WHERE files.id = ? AND (files.owner = ? OR EXISTS ( + SELECT 1 FROM user_access + WHERE user_access.user_id = ? AND user_access.file_id = ?) + )`, + [fileId, userId, userId, fileId], + ) || {}; + + return accessCount || 0; +} + +export function checkFilePermission(fileId, userId) { + return ( + getAccountDb().first( + `SELECT 1 as granted + FROM files + WHERE files.id = ? and (files.owner = ?)`, + [fileId, userId], + ) || { granted: 0 } + ); +} + +export function addUserAccess(userId, fileId) { + if (!userId || !fileId) { + throw new Error('Invalid parameters'); + } + try { + const userExists = getUserById(userId); + const fileExists = getFileById(fileId); + if (!userExists || !fileExists) { + throw new Error('User or file not found'); + } + getAccountDb().mutate( + 'INSERT INTO user_access (user_id, file_id) VALUES (?, ?)', + [userId, fileId], + ); + } catch (error) { + if (error.message.includes('UNIQUE constraint')) { + throw new Error('Access already exists'); + } + throw new Error(`Failed to add user access: ${error.message}`); + } +} + +export function deleteUserAccessByFileId(userIds, fileId) { + if (!Array.isArray(userIds) || userIds.length === 0) { + throw new Error('The provided userIds must be a non-empty array.'); + } + + const CHUNK_SIZE = 999; + let totalChanges = 0; + + try { + getAccountDb().transaction(() => { + for (let i = 0; i < userIds.length; i += CHUNK_SIZE) { + const chunk = userIds.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + + const sql = `DELETE FROM user_access WHERE user_id IN (${placeholders}) AND file_id = ?`; + + const result = getAccountDb().mutate(sql, [...chunk, fileId]); + totalChanges += result.changes; + } + }); + } catch (error) { + throw new Error(`Failed to delete user access: ${error.message}`); + } + + return totalChanges; +} + +export function getAllUserAccess(fileId) { + return getAccountDb().all( + `SELECT users.id as userId, user_name as userName, display_name as displayName, + CASE WHEN user_access.file_id IS NULL THEN 0 ELSE 1 END as haveAccess, + CASE WHEN files.id IS NULL THEN 0 ELSE 1 END as owner + FROM users + LEFT JOIN user_access ON user_access.file_id = ? and user_access.user_id = users.id + LEFT JOIN files ON files.id = ? and files.owner = users.id + WHERE users.enabled = 1 AND users.user_name <> ''`, + [fileId, fileId], + ); +} + +export function getOpenIDConfig() { + return ( + getAccountDb().first(`SELECT * FROM auth WHERE method = ?`, ['openid']) || + null + ); +} diff --git a/src/util/middlewares.js b/src/util/middlewares.js index 5bbec6946..f8d6b4f18 100644 --- a/src/util/middlewares.js +++ b/src/util/middlewares.js @@ -1,4 +1,4 @@ -import validateUser from './validate-user.js'; +import validateSession from './validate-user.js'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; @@ -31,11 +31,13 @@ async function errorMiddleware(err, req, res, next) { * @param {import('express').Response} res * @param {import('express').NextFunction} next */ -const validateUserMiddleware = async (req, res, next) => { - let user = await validateUser(req, res); - if (!user) { +const validateSessionMiddleware = async (req, res, next) => { + let session = await validateSession(req, res); + if (!session) { return; } + + res.locals = session; next(); }; @@ -53,4 +55,4 @@ const requestLoggerMiddleware = expressWinston.logger({ ), }); -export { validateUserMiddleware, errorMiddleware, requestLoggerMiddleware }; +export { validateSessionMiddleware, errorMiddleware, requestLoggerMiddleware }; diff --git a/src/util/validate-user.js b/src/util/validate-user.js index 117fb779b..a84389e6f 100644 --- a/src/util/validate-user.js +++ b/src/util/validate-user.js @@ -1,13 +1,16 @@ -import { getSession } from '../account-db.js'; import config from '../load-config.js'; import proxyaddr from 'proxy-addr'; import ipaddr from 'ipaddr.js'; +import { getSession } from '../account-db.js'; + +export const TOKEN_EXPIRATION_NEVER = -1; +const MS_PER_SECOND = 1000; /** * @param {import('express').Request} req * @param {import('express').Response} res */ -export default function validateUser(req, res) { +export default function validateSession(req, res) { let { token } = req.body || {}; if (!token) { @@ -26,6 +29,18 @@ export default function validateUser(req, res) { return null; } + if ( + session.expires_at !== TOKEN_EXPIRATION_NEVER && + session.expires_at * MS_PER_SECOND <= Date.now() + ) { + res.status(401); + res.send({ + status: 'error', + reason: 'token-expired', + }); + return null; + } + return session; } diff --git a/tsconfig.json b/tsconfig.json index 2a3511698..cb09bd65f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,6 @@ "module": "node16", "outDir": "build" }, + "include": ["src/**/*.js", "types/global.d.ts"], "exclude": ["node_modules", "build", "./app-plaid.js", "coverage"], } diff --git a/upcoming-release-notes/498.md b/upcoming-release-notes/498.md new file mode 100644 index 000000000..e1b8c807d --- /dev/null +++ b/upcoming-release-notes/498.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [apilat, lelemm] +--- + +Add support for authentication using OpenID Connect. diff --git a/yarn.lock b/yarn.lock index 2d8c7c48c..6527296e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1561,6 +1561,7 @@ __metadata: jws: "npm:^4.0.0" migrate: "npm:^2.0.1" nordigen-node: "npm:^1.4.0" + openid-client: "npm:^5.4.2" prettier: "npm:^2.8.3" supertest: "npm:^6.3.1" typescript: "npm:^4.9.5" @@ -4254,6 +4255,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.15.5": + version: 4.15.9 + resolution: "jose@npm:4.15.9" + checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4467,6 +4475,15 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 + languageName: node + linkType: hard + "make-dir@npm:^3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -4953,6 +4970,13 @@ __metadata: languageName: node linkType: hard +"object-hash@npm:^2.2.0": + version: 2.2.0 + resolution: "object-hash@npm:2.2.0" + checksum: 10c0/1527de843926c5442ed61f8bdddfc7dc181b6497f725b0e89fcf50a55d9c803088763ed447cac85a5aa65345f1e99c2469ba679a54349ef3c4c0aeaa396a3eb9 + languageName: node + linkType: hard + "object-inspect@npm:^1.13.1": version: 1.13.1 resolution: "object-inspect@npm:1.13.1" @@ -4960,6 +4984,13 @@ __metadata: languageName: node linkType: hard +"oidc-token-hash@npm:^5.0.3": + version: 5.0.3 + resolution: "oidc-token-hash@npm:5.0.3" + checksum: 10c0/d0dc0551406f09577874155cc83cf69c39e4b826293d50bb6c37936698aeca17d4bcee356ab910c859e53e83f2728a2acbd041020165191353b29de51fbca615 + languageName: node + linkType: hard + "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -5003,6 +5034,18 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^5.4.2": + version: 5.6.5 + resolution: "openid-client@npm:5.6.5" + dependencies: + jose: "npm:^4.15.5" + lru-cache: "npm:^6.0.0" + object-hash: "npm:^2.2.0" + oidc-token-hash: "npm:^5.0.3" + checksum: 10c0/4308dcd37a9ffb1efc2ede0bc556ae42ccc2569e71baa52a03ddfa44407bf403d4534286f6f571381c5eaa1845c609ed699a5eb0d350acfb8c3bacb72c2a6890 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4"