mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 10:33:02 -05:00
OpenID (#498)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
41
migrations/1718889148000-openid.js
Normal file
41
migrations/1718889148000-openid.js
Normal file
@@ -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;
|
||||
`,
|
||||
);
|
||||
};
|
||||
104
migrations/1719409568000-multiuser.js
Normal file
104
migrations/1719409568000-multiuser.js
Normal file
@@ -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;
|
||||
`,
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
let accountDb = getAccountDb();
|
||||
let rows = accountDb.all('SELECT * FROM auth');
|
||||
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 (rows.length !== 0) {
|
||||
return { error: 'already-bootstrapped' };
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (password === undefined || password === '') {
|
||||
return { error: 'invalid-password' };
|
||||
}
|
||||
|
||||
let accountDb = getAccountDb();
|
||||
let row = accountDb.first('SELECT * FROM auth');
|
||||
|
||||
let confirmed = row && bcrypt.compareSync(password, row.password);
|
||||
|
||||
if (!confirmed) {
|
||||
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 isAdmin(userId) {
|
||||
return hasPermission(userId, 'ADMIN');
|
||||
}
|
||||
|
||||
export function changePassword(newPassword) {
|
||||
if (newPassword === undefined || newPassword === '') {
|
||||
return { error: 'invalid-password' };
|
||||
export function hasPermission(userId, permission) {
|
||||
return getUserPermission(userId) === permission;
|
||||
}
|
||||
|
||||
export async function enableOpenID(loginSettings) {
|
||||
if (!loginSettings || !loginSettings.openId) {
|
||||
return { error: 'invalid-login-settings' };
|
||||
}
|
||||
|
||||
let { error } = (await bootstrapOpenId(loginSettings.openId)) || {};
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
getAccountDb().mutate('DELETE FROM sessions');
|
||||
}
|
||||
|
||||
export async function disableOpenID(loginSettings) {
|
||||
if (!loginSettings || !loginSettings.password) {
|
||||
return { error: 'invalid-login-settings' };
|
||||
}
|
||||
|
||||
let accountDb = getAccountDb();
|
||||
const { extra_data: passwordHash } =
|
||||
accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [
|
||||
'password',
|
||||
]) || {};
|
||||
|
||||
let hashed = hashPassword(newPassword);
|
||||
let token = uuid.v4();
|
||||
if (!passwordHash) {
|
||||
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]);
|
||||
if (!loginSettings?.password) {
|
||||
return { error: 'invalid-password' };
|
||||
}
|
||||
|
||||
return {};
|
||||
if (passwordHash) {
|
||||
let confirmed = bcrypt.compareSync(loginSettings.password, passwordHash);
|
||||
|
||||
if (!confirmed) {
|
||||
return { error: 'invalid-password' };
|
||||
}
|
||||
}
|
||||
|
||||
let { error } = (await bootstrapPassword(loginSettings.password)) || {};
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
316
src/accounts/openid.js
Normal file
316
src/accounts/openid.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
124
src/accounts/password.js
Normal file
124
src/accounts/password.js
Normal file
@@ -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 {};
|
||||
}
|
||||
@@ -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: { token } });
|
||||
res.send({ status: 'ok', data: boot });
|
||||
});
|
||||
|
||||
app.post('/login', (req, res) => {
|
||||
app.get('/login-methods', (req, res) => {
|
||||
let methods = listLoginMethods();
|
||||
res.send({ status: 'ok', methods });
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
409
src/app-admin.js
Normal file
409
src/app-admin.js
Normal file
@@ -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);
|
||||
380
src/app-admin.test.js
Normal file
380
src/app-admin.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
101
src/app-openid.js
Normal file
101
src/app-openid.js
Normal file
@@ -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);
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 + '...');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
261
src/services/user-service.js
Normal file
261
src/services/user-service.js
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"module": "node16",
|
||||
"outDir": "build"
|
||||
},
|
||||
"include": ["src/**/*.js", "types/global.d.ts"],
|
||||
"exclude": ["node_modules", "build", "./app-plaid.js", "coverage"],
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/498.md
Normal file
6
upcoming-release-notes/498.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [apilat, lelemm]
|
||||
---
|
||||
|
||||
Add support for authentication using OpenID Connect.
|
||||
43
yarn.lock
43
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"
|
||||
|
||||
Reference in New Issue
Block a user