diff --git a/migrations/20260217120000-mailPasswords-create-table.js b/migrations/20260217120000-mailPasswords-create-table.js new file mode 100644 index 000000000..ae1210109 --- /dev/null +++ b/migrations/20260217120000-mailPasswords-create-table.js @@ -0,0 +1,18 @@ +'use strict'; + +exports.up = async function(db) { + const cmd = 'CREATE TABLE mailPasswords(' + + 'appId VARCHAR(128) NOT NULL,' + + 'userId VARCHAR(128) NOT NULL,' + + 'password VARCHAR(1024) NOT NULL,' + + 'creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' + + 'FOREIGN KEY(appId) REFERENCES apps(id),' + + 'FOREIGN KEY(userId) REFERENCES users(id),' + + 'PRIMARY KEY (appId, userId)) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin'; + + await db.runSql(cmd); +}; + +exports.down = async function(db) { + await db.runSql('DROP TABLE mailPasswords'); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 7af3f24d3..3d8645c85 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -300,6 +300,16 @@ CREATE TABLE IF NOT EXISTS appPasswords( PRIMARY KEY (id) ); +CREATE TABLE IF NOT EXISTS mailPasswords( + appId VARCHAR(128) NOT NULL, + userId VARCHAR(128) NOT NULL, + password VARCHAR(1024) NOT NULL, + creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(appId) REFERENCES apps(id), + FOREIGN KEY(userId) REFERENCES users(id), + PRIMARY KEY (appId, userId) +); + CREATE TABLE IF NOT EXISTS dockerRegistries( id VARCHAR(128) NOT NULL UNIQUE, provider VARCHAR(16) NOT NULL, diff --git a/src/apps.js b/src/apps.js index 302685339..d138e604a 100644 --- a/src/apps.js +++ b/src/apps.js @@ -903,13 +903,14 @@ async function del(id) { { query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] }, { query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] }, { query: 'DELETE FROM appPasswords WHERE identifier = ?', args: [ id ] }, + { query: 'DELETE FROM mailPasswords WHERE appId = ?', args: [ id ] }, { query: 'DELETE FROM appMounts WHERE appId = ?', args: [ id ] }, { query: `UPDATE backupSites SET contentsJson = JSON_REMOVE(contentsJson, JSON_UNQUOTE(JSON_SEARCH(contentsJson, 'one', ?, NULL, '$.*[*]'))) WHERE contentsJson LIKE ${mysql.escape('%"' + id + '"%')}`, args: [ id ] }, { query: 'DELETE FROM apps WHERE id = ?', args: [ id ] } ]; const results = await database.transaction(queries); - if (results[6].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found'); + if (results[7].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found'); } async function clear() { diff --git a/src/mailpasswords.js b/src/mailpasswords.js new file mode 100644 index 000000000..bd6aa5194 --- /dev/null +++ b/src/mailpasswords.js @@ -0,0 +1,56 @@ +import assert from 'node:assert'; +import BoxError from './boxerror.js'; +import database from './database.js'; +import safe from 'safetydance'; + +const MAIL_PASSWORD_FIELDS = [ 'appId', 'userId', 'password', 'creationTime' ].join(','); + +async function get(appId, userId) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof userId, 'string'); + + const result = await database.query('SELECT ' + MAIL_PASSWORD_FIELDS + ' FROM mailPasswords WHERE appId = ? AND userId = ?', [ appId, userId ]); + if (result.length === 0) return null; + return result[0]; +} + +async function add(appId, userId, password) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof password, 'string'); + + if (appId.length < 1) throw new BoxError(BoxError.BAD_FIELD, 'appId must be at least 1 char'); + if (userId.length < 1) throw new BoxError(BoxError.BAD_FIELD, 'userId must be at least 1 char'); + if (password.length < 1) throw new BoxError(BoxError.BAD_FIELD, 'password must be at least 1 char'); + + const query = 'INSERT INTO mailPasswords (appId, userId, password) VALUES (?, ?, ?)'; + const args = [ appId, userId, password ]; + + const [error] = await safe(database.query(query, args)); + if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'mail password for this app and user already exists'); + if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'app or user not found'); + if (error) throw error; + + return { appId, userId }; +} + +async function list(userId) { + assert.strictEqual(typeof userId, 'string'); + + return await database.query('SELECT ' + MAIL_PASSWORD_FIELDS + ' FROM mailPasswords WHERE userId = ? ORDER BY appId', [ userId ]); +} + +async function del(appId, userId) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof userId, 'string'); + + const result = await database.query('DELETE FROM mailPasswords WHERE appId = ? AND userId = ?', [ appId, userId ]); + if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'mail password not found'); +} + +export default { + get, + add, + list, + del +}; diff --git a/src/oidcserver.js b/src/oidcserver.js index f9d495fba..9e13f6b42 100644 --- a/src/oidcserver.js +++ b/src/oidcserver.js @@ -32,6 +32,7 @@ import users from './users.js'; import groups from './groups.js'; import util from 'node:util'; import Provider from 'oidc-provider'; +import mailpasswords from './mailpasswords.js'; const debug = debugModule('box:oidcserver'); @@ -535,6 +536,8 @@ async function getClaims(username/*, use, scope*/) { const [mailboxesError, mailboxes] = await safe(mail.listMailboxesByUserId(user.id)); if (mailboxesError) return { error: mailboxesError.message }; + // const [mailPasswordError, mailPassword] = await safe(mailpasswords.get()) + const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null const { firstName, lastName, middleName } = users.parseDisplayName(displayName); diff --git a/src/test/mailpasswords-test.js b/src/test/mailpasswords-test.js new file mode 100644 index 000000000..8c6044ae5 --- /dev/null +++ b/src/test/mailpasswords-test.js @@ -0,0 +1,84 @@ +/* jslint node:true */ + +import mailPasswords from '../mailpasswords.js'; +import BoxError from '../boxerror.js'; +import common from './common.js'; +import expect from 'expect.js'; +import safe from 'safetydance'; + +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +describe('Mail passwords', function () { + const { setup, cleanup, admin, app } = common; + + before(setup); + after(cleanup); + + it('cannot add with empty appId', async function () { + const [error] = await safe(mailPasswords.add('', admin.id, 'token123')); + expect(error.reason).to.be(BoxError.BAD_FIELD); + }); + + it('cannot add with empty userId', async function () { + const [error] = await safe(mailPasswords.add(app.id, '', 'token123')); + expect(error.reason).to.be(BoxError.BAD_FIELD); + }); + + it('cannot add with empty password', async function () { + const [error] = await safe(mailPasswords.add(app.id, admin.id, '')); + expect(error.reason).to.be(BoxError.BAD_FIELD); + }); + + it('can add mail password', async function () { + const result = await mailPasswords.add(app.id, admin.id, 'secret-token'); + expect(result.appId).to.be(app.id); + expect(result.userId).to.be(admin.id); + }); + + it('can get mail password', async function () { + const result = await mailPasswords.get(app.id, admin.id); + expect(result).to.be.ok(); + expect(result.appId).to.be(app.id); + expect(result.userId).to.be(admin.id); + expect(result.password).to.be('secret-token'); + }); + + it('cannot get random mail password', async function () { + const result = await mailPasswords.get('random', 'random'); + expect(result).to.be(null); + }); + + it('can list mail passwords for user', async function () { + const results = await mailPasswords.list(admin.id); + expect(results.length).to.be(1); + expect(results[0].appId).to.be(app.id); + expect(results[0].userId).to.be(admin.id); + }); + + it('cannot add duplicate appId and userId', async function () { + const [error] = await safe(mailPasswords.add(app.id, admin.id, 'another-token')); + expect(error.reason).to.be(BoxError.ALREADY_EXISTS); + }); + + it('can del mail password', async function () { + await mailPasswords.del(app.id, admin.id); + }); + + it('cannot del random mail password', async function () { + const [error] = await safe(mailPasswords.del('random', 'random')); + expect(error.reason).to.be(BoxError.NOT_FOUND); + }); + + it('cannot add with non-existent appId', async function () { + const [error] = await safe(mailPasswords.add('nonexistent-app-id', admin.id, 'token')); + expect(error.reason).to.be(BoxError.NOT_FOUND); + }); + + it('cannot add with non-existent userId', async function () { + const [error] = await safe(mailPasswords.add(app.id, 'nonexistent-user-id', 'token')); + expect(error.reason).to.be(BoxError.NOT_FOUND); + }); +}); diff --git a/src/users.js b/src/users.js index 1d0c1cf1b..9ef3a92e3 100644 --- a/src/users.js +++ b/src/users.js @@ -266,6 +266,7 @@ async function del(user, auditSource) { { query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] }, { query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] }, { query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] }, + { query: 'DELETE FROM mailPasswords WHERE userId = ?', args: [ user.id ] }, { query: 'DELETE FROM passkeys WHERE userId = ?', args: [ user.id ] }, { query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] }, // keep this the last query as we check affectedRows below ];