This commit is contained in:
lelemm
2024-11-23 08:55:56 -03:00
committed by GitHub
parent 30f21497a6
commit 826511779e
27 changed files with 2399 additions and 125 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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 dont have a password set yet. Lets 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. Lets 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);
}
}

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

View File

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

View File

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

View File

@@ -17,5 +17,6 @@
"module": "node16",
"outDir": "build"
},
"include": ["src/**/*.js", "types/global.d.ts"],
"exclude": ["node_modules", "build", "./app-plaid.js", "coverage"],
}

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [apilat, lelemm]
---
Add support for authentication using OpenID Connect.

View File

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