diff --git a/backend/.oxfmtrc.json b/.oxfmtrc.json similarity index 67% rename from backend/.oxfmtrc.json rename to .oxfmtrc.json index 518e8c95e..3e1479207 100644 --- a/backend/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,5 +1,5 @@ { - "$schema": "./node_modules/oxfmt/configuration_schema.json", + "$schema": "./backend/node_modules/oxfmt/configuration_schema.json", "semi": false, "singleQuote": true, "trailingComma": "none", diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a05d1f37..de1542341 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,14 @@ { "eslint.enable": false, - "editor.formatOnSave": false, "editor.tabSize": 2, - "i18n-ally.localesPaths": [ - "backend/locales", - ], + "i18n-ally.localesPaths": ["backend/locales"], "i18n-ally.pathMatcher": "{locale}.json", "i18n-ally.keystyle": "flat", "i18n-ally.sortKeys": true, - "i18n-ally.enabledFrameworks": [ - "vue" - ] + "i18n-ally.enabledFrameworks": ["vue"], + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file" + } } diff --git a/backend/api/authentication.mjs b/backend/api/authentication.mjs index 08da2b058..41d2f1688 100644 --- a/backend/api/authentication.mjs +++ b/backend/api/authentication.mjs @@ -1,100 +1,204 @@ - /** * Authentication API Routes */ -async function routes (app, options) { +async function routes(app, options) { /** * GET SITE AUTHENTICATION STRATEGIES */ - app.get('/sites/:siteId/auth/strategies', { - schema: { - summary: 'List all site authentication strategies', - tags: ['Authentication'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' + app.get( + '/sites/:siteId/auth/strategies', + { + schema: { + summary: 'List all site authentication strategies', + tags: ['Authentication'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } } - } - }, - querystring: { - type: 'object', - properties: { - visibleOnly: { - type: 'boolean', - default: false + }, + querystring: { + type: 'object', + properties: { + visibleOnly: { + type: 'boolean', + default: false + } } } } - } - }, async (req, reply) => { - const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) - const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true }) - const siteStrategies = activeStrategies.map(str => { - const authModule = WIKI.data.authentication.find(m => m.key === str.module) - const siteStr = site.config.authStrategies.find(s => s.id === str.id) || {} - return { - id: str.id, - displayName: str.displayName, - useForm: authModule.useForm, - usernameType: authModule.usernameType, - color: authModule.color, - icon: authModule.icon, - order: siteStr.order ?? 0, - isVisible: siteStr.isVisible ?? false + }, + async (req, reply) => { + const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + if (!site) { + return reply.badRequest('Invalid Site ID') } - }).sort((a,b) => a.order - b.order) - return req.query.visibleOnly ? siteStrategies.filter(s => s.isVisible) : siteStrategies - }) + const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true }) + const siteStrategies = activeStrategies + .map((str) => { + const authModule = WIKI.data.authentication.find((m) => m.key === str.module) + const siteStr = site.config.authStrategies.find((s) => s.id === str.id) || {} + return { + id: str.id, + displayName: str.displayName, + useForm: authModule.useForm, + usernameType: authModule.usernameType, + color: authModule.color, + icon: authModule.icon, + order: siteStr.order ?? 0, + isVisible: siteStr.isVisible ?? false + } + }) + .sort((a, b) => a.order - b.order) + return req.query.visibleOnly ? siteStrategies.filter((s) => s.isVisible) : siteStrategies + } + ) /** * LOGIN USING USER/PASS */ - app.post('/sites/:siteId/auth/login', { - schema: { - summary: 'Login', - tags: ['Authentication'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' + app.put( + '/sites/:siteId/auth/login', + { + schema: { + summary: 'Login', + tags: ['Authentication'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + } + }, + body: { + type: 'object', + required: ['strategyId'], + properties: { + strategyId: { + type: 'string', + format: 'uuid' + }, + username: { + type: 'string', + minLength: 1, + maxLength: 255 + }, + password: { + type: 'string', + minLength: 1, + maxLength: 255 + } } } - }, - body: { - type: 'object', - required: ['strategyId', 'username', 'password'], - properties: { - strategyId: { - type: 'string', - format: 'uuid' - }, - username: { - type: 'string', - minLength: 1, - maxLength: 255 + } + }, + async (req, reply) => { + try { + const result = await WIKI.models.users.login( + { + siteId: req.params.siteId, + strategyId: req.body.strategyId, + username: req.body.username, + password: req.body.password, + ip: req.ip }, - password: { - type: 'string', - minLength: 1, - maxLength: 255 + req + ) + if (!result) { + throw new Error('Unexpected empty login response.') + } + return { + ok: true, + ...result + } + } catch (err) { + if (err.message.startsWith('ERR_')) { + return reply.badRequest(err.message) + } else { + WIKI.logger.info(err) // TODO: change to debug once stable + return reply.badRequest('ERR_LOGIN_FAILED') + } + } + } + ) + + /** + * CHANGE PASSWORD + */ + app.put( + '/sites/:siteId/auth/changePassword', + { + schema: { + summary: 'Change Password From Login', + tags: ['Authentication'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + } + }, + body: { + type: 'object', + required: ['strategyId', 'continuationToken', 'newPassword'], + properties: { + strategyId: { + type: 'string', + format: 'uuid' + }, + continuationToken: { + type: 'string', + minLength: 1, + maxLength: 255 + }, + newPassword: { + type: 'string', + minLength: 1, + maxLength: 255 + } } } } + }, + async (req, reply) => { + try { + const result = await WIKI.models.users.loginChangePassword( + { + siteId: req.params.siteId, + strategyId: req.body.strategyId, + continuationToken: req.body.continuationToken, + newPassword: req.body.newPassword, + ip: req.ip + }, + req + ) + if (!result) { + throw new Error('Unexpected empty change password response.') + } + if (result?.authenticated) { + req.session.authenticated = true + } + return { + ok: true, + ...result + } + } catch (err) { + if (err.message.startsWith('ERR_')) { + return reply.badRequest(err.message) + } else { + WIKI.logger.debug(err) + return reply.badRequest('ERR_CHANGE_PASSWORD_FAILED') + } + } } - }, async (req, reply) => { - return WIKI.models.users.login({ - siteId: req.params.siteId, - strategyId: req.body.strategyId, - username: req.body.username, - password: req.body.password, - ip: req.ip - }) - }) + ) } export default routes diff --git a/backend/api/pages.mjs b/backend/api/pages.mjs index 7c4152b6e..c92faa757 100644 --- a/backend/api/pages.mjs +++ b/backend/api/pages.mjs @@ -1,92 +1,101 @@ /** * Pages API Routes */ -async function routes (app, options) { - app.get('/sites/:siteId/pages', { - schema: { - summary: 'List all pages', - tags: ['Pages'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' +async function routes(app, options) { + app.get( + '/sites/:siteId/pages', + { + schema: { + summary: 'List all pages', + tags: ['Pages'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } } } } + }, + async (req, reply) => { + return [] } - }, async (req, reply) => { - return [] - }) + ) - app.get('/sites/:siteId/pages/:pageIdOrHash', { - schema: { - summary: 'List all pages', - tags: ['Pages'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' - }, - pageIdOrHash: { - type: 'string', - oneOf: [ - { format: 'uuid' }, - { pattern: '^[a-f0-9]+$' } - ] + app.get( + '/sites/:siteId/pages/:pageIdOrHash', + { + schema: { + summary: 'List all pages', + tags: ['Pages'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + }, + pageIdOrHash: { + type: 'string', + oneOf: [{ format: 'uuid' }, { pattern: '^[a-f0-9]+$' }] + } } - } - }, - querystring: { - type: 'object', - properties: { - withContent: { - type: 'boolean', - default: false + }, + querystring: { + type: 'object', + properties: { + withContent: { + type: 'boolean', + default: false + } } } } + }, + async (req, reply) => { + return [] } - }, async (req, reply) => { - return [] - }) + ) - app.post('/sites/:siteId/pages/userPermissions', { - schema: { - summary: 'Get page user permissions', - tags: ['Pages'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' - } - } - }, - body: { - type: 'object', - required: ['path'], - properties: { - path: { - type: 'string', - minLength: 1, - maxLength: 255 + app.post( + '/sites/:siteId/pages/userPermissions', + { + schema: { + summary: 'Get page user permissions', + tags: ['Pages'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } } }, - examples: [ - { - path: 'foo/bar' - } - ] + body: { + type: 'object', + required: ['path'], + properties: { + path: { + type: 'string', + minLength: 1, + maxLength: 255 + } + }, + examples: [ + { + path: 'foo/bar' + } + ] + } } + }, + async (req, reply) => { + return [] } - }, async (req, reply) => { - return [] - }) + ) } export default routes diff --git a/backend/api/sites.mjs b/backend/api/sites.mjs index e7e4d210a..578152150 100644 --- a/backend/api/sites.mjs +++ b/backend/api/sites.mjs @@ -3,166 +3,194 @@ import { validate as uuidValidate } from 'uuid' /** * Sites API Routes */ -async function routes (app, options) { - app.get('/', { - schema: { - summary: 'List all sites', - tags: ['Sites'] +async function routes(app, options) { + app.get( + '/', + { + config: { + permissions: ['read:sites', 'read:dashboard'] + }, + schema: { + summary: 'List all sites', + tags: ['Sites'] + } + }, + async (req, reply) => { + const sites = await WIKI.models.sites.getAllSites() + return sites.map((s) => ({ + ...s.config, + id: s.id, + hostname: s.hostname, + isEnabled: s.isEnabled, + pageExtensions: s.config.pageExtensions.join(', ') + })) } - }, async (req, reply) => { - return { hello: 'world' } - }) + ) - app.get('/:siteIdorHostname', { - schema: { - summary: 'Get site info', - tags: ['Sites'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - description: 'Either a site ID, hostname or "current" to use the request hostname.', - oneOf: [ - { format: 'uuid' }, - { enum: ['current'] }, - { pattern: '^[a-f0-9]+$' } - ] + app.get( + '/:siteIdorHostname', + { + schema: { + summary: 'Get site info', + tags: ['Sites'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + description: 'Either a site ID, hostname or "current" to use the request hostname.', + oneOf: [{ format: 'uuid' }, { enum: ['current'] }, { pattern: '^[a-f0-9]+$' }] + } + }, + required: ['siteIdorHostname'] + } + } + }, + async (req, reply) => { + let site + if (req.params.siteId === 'current' && req.hostname) { + site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) + } else if (uuidValidate(req.params.siteId)) { + site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) + } else { + site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId }) + } + return site + ? { + ...site.config, + id: site.id, + hostname: site.hostname, + isEnabled: site.isEnabled } - }, - required: ['siteIdorHostname'] - }, + : null } - }, async (req, reply) => { - let site - if (req.params.siteId === 'current' && req.hostname) { - site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) - } else if (uuidValidate(req.params.siteId)) { - site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) - } else { - site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId }) - } - return site - ? { - ...site.config, - id: site.id, - hostname: site.hostname, - isEnabled: site.isEnabled - } - : null - }) + ) /** * CREATE SITE */ - app.post('/', { - config: { - // permissions: ['create:sites', 'manage:sites'] - }, - schema: { - summary: 'Create a new site', - tags: ['Sites'], - body: { - type: 'object', - required: ['hostname', 'title'], - properties: { - hostname: { - type: 'string', - minLength: 1, - maxLength: 255, - pattern: '^(\\*|[a-z0-9.-]+)$' - }, - title: { - type: 'string', - minLength: 1, - maxLength: 255 - } - }, - examples: [ - { - hostname: 'wiki.example.org', - title: 'My Wiki Site' - } - ] + app.post( + '/', + { + config: { + // permissions: ['create:sites', 'manage:sites'] }, - response: { - 200: { - description: 'Site created successfully', + schema: { + summary: 'Create a new site', + tags: ['Sites'], + body: { type: 'object', + required: ['hostname', 'title'], properties: { - message: { - type: 'string' + hostname: { + type: 'string', + minLength: 1, + maxLength: 255, + pattern: '^(\\*|[a-z0-9.-]+)$' }, - id: { + title: { type: 'string', - format: 'uuid' + minLength: 1, + maxLength: 255 + } + }, + examples: [ + { + hostname: 'wiki.example.org', + title: 'My Wiki Site' + } + ] + }, + response: { + 200: { + description: 'Site created successfully', + type: 'object', + properties: { + message: { + type: 'string' + }, + id: { + type: 'string', + format: 'uuid' + } } } } } + }, + async (req, reply) => { + const result = await WIKI.models.sites.createSite(req.body.hostname, { + title: req.body.title + }) + return { + message: 'Site created successfully.', + id: result.id + } } - }, async (req, reply) => { - const result = await WIKI.models.sites.createSite(req.body.hostname, { title: req.body.title }) - return { - message: 'Site created successfully.', - id: result.id - } - }) + ) /** * UPDATE SITE */ - app.put('/:siteId', { - config: { - permissions: ['manage:sites'] + app.put( + '/:siteId', + { + config: { + permissions: ['manage:sites'] + }, + schema: { + summary: 'Update a site', + tags: ['Sites'] + } }, - schema: { - summary: 'Update a site', - tags: ['Sites'] + async (req, reply) => { + return { hello: 'world' } } - }, async (req, reply) => { - return { hello: 'world' } - }) + ) /** * DELETE SITE */ - app.delete('/:siteId', { - config: { - permissions: ['manage:sites'] - }, - schema: { - summary: 'Delete a site', - tags: ['Sites'], - params: { - type: 'object', - properties: { - siteId: { - type: 'string', - format: 'uuid' - } - }, - required: ['siteId'] + app.delete( + '/:siteId', + { + config: { + permissions: ['manage:sites'] }, - response: { - 204: { - description: 'Site deleted successfully' + schema: { + summary: 'Delete a site', + tags: ['Sites'], + params: { + type: 'object', + properties: { + siteId: { + type: 'string', + format: 'uuid' + } + }, + required: ['siteId'] + }, + response: { + 204: { + description: 'Site deleted successfully' + } } } - } - }, async (req, reply) => { - try { - if (await WIKI.models.sites.countSites() <= 1) { - reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.') - } else if (await WIKI.models.sites.deleteSite(req.params.siteId)) { - reply.code(204) - } else { - reply.badRequest('Site does not exist.') + }, + async (req, reply) => { + try { + if ((await WIKI.models.sites.countSites()) <= 1) { + reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.') + } else if (await WIKI.models.sites.deleteSite(req.params.siteId)) { + reply.code(204) + } else { + reply.badRequest('Site does not exist.') + } + } catch (err) { + reply.send(err) } - } catch (err) { - reply.send(err) } - }) + ) } export default routes diff --git a/backend/api/system.mjs b/backend/api/system.mjs index f74cddcc2..f46fdb9b7 100644 --- a/backend/api/system.mjs +++ b/backend/api/system.mjs @@ -1,24 +1,74 @@ +import path from 'node:path' +import os from 'node:os' +import { DateTime } from 'luxon' +import { filesize } from 'filesize' +import { isNil } from 'es-toolkit/predicate' +import { gte, sql } from 'drizzle-orm' +import { + groups as groupsTable, + pages as pagesTable, + tags as tagsTable, + users as usersTable +} from '../db/schema.mjs' + /** * System API Routes */ -async function routes (app, options) { - app.get('/info', { - schema: { - summary: 'System Info', - tags: ['System'] +async function routes(app, options) { + app.get( + '/info', + { + config: { + permissions: ['read:dashboard', 'manage:sites'] + }, + schema: { + summary: 'System Info', + tags: ['System'] + } + }, + async (request, reply) => { + return { + configFile: path.join(process.cwd(), 'config.yml'), + cpuCores: os.cpus().length, + currentVersion: WIKI.version, + dbHost: WIKI.config.db.host, + dbVersion: WIKI.dbManager.VERSION, + groupsTotal: await WIKI.db.$count(groupsTable), + hostname: os.hostname(), + httpPort: 0, + isMailConfigured: WIKI.config?.mail?.host?.length > 2, + isSchedulerHealthy: true, + latestVersion: WIKI.config.update.version, + latestVersionReleaseDate: DateTime.fromISO(WIKI.config.update.versionDate).toJSDate(), + loginsPastDay: await WIKI.db.$count( + usersTable, + gte(usersTable.lastLoginAt, sql`NOW() - INTERVAL '1 DAY'`) + ), + nodeVersion: process.version.substring(1), + operatingSystem: `${os.type()} (${os.platform()}) ${os.release()} ${os.arch()}`, + pagesTotal: await WIKI.db.$count(pagesTable), + platform: os.platform(), + ramTotal: filesize(os.totalmem()), + tagsTotal: await WIKI.db.$count(tagsTable), + upgradeCapable: !isNil(process.env.UPGRADE_COMPANION), + usersTotal: await WIKI.db.$count(usersTable), + workingDirectory: process.cwd() + } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) - app.get('/flags', { - schema: { - summary: 'System Flags', - tags: ['System'] + app.get( + '/flags', + { + schema: { + summary: 'System Flags', + tags: ['System'] + } + }, + async (request, reply) => { + return WIKI.config.flags } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) } export default routes diff --git a/backend/api/users.mjs b/backend/api/users.mjs index af3fc2096..bfc13f7b9 100644 --- a/backend/api/users.mjs +++ b/backend/api/users.mjs @@ -1,51 +1,95 @@ /** * Users API Routes */ -async function routes (app, options) { - app.get('/', { - schema: { - summary: 'List all users', - tags: ['Users'] +async function routes(app, options) { + app.get( + '/', + { + schema: { + summary: 'List all users', + tags: ['Users'] + } + }, + async (request, reply) => { + return { hello: 'world' } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) - app.get('/:userId', { - schema: { - summary: 'Get user info', - tags: ['Users'] + app.get( + '/whoami', + { + schema: { + summary: 'Get currently logged in user info', + tags: ['Users'] + } + }, + async (req, reply) => { + reply.preventCache() + if (req.session?.authenticated) { + return { + authenticated: true, + ...req.session.user, + permissions: ['manage:system'] + } + } else { + return { + authenticated: false + } + } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) - app.post('/', { - schema: { - summary: 'Create a new user', - tags: ['Users'] + app.get( + '/:userId', + { + schema: { + summary: 'Get user info', + tags: ['Users'] + } + }, + async (request, reply) => { + return { hello: 'world' } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) - app.put('/:userId', { - schema: { - summary: 'Update a user', - tags: ['Users'] + app.post( + '/', + { + schema: { + summary: 'Create a new user', + tags: ['Users'] + } + }, + async (request, reply) => { + return { hello: 'world' } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) - app.delete('/:userId', { - schema: { - summary: 'Delete a user', - tags: ['Users'] + app.put( + '/:userId', + { + schema: { + summary: 'Update a user', + tags: ['Users'] + } + }, + async (request, reply) => { + return { hello: 'world' } } - }, async (request, reply) => { - return { hello: 'world' } - }) + ) + + app.delete( + '/:userId', + { + schema: { + summary: 'Delete a user', + tags: ['Users'] + } + }, + async (request, reply) => { + return { hello: 'world' } + } + ) } export default routes diff --git a/backend/core/db.mjs b/backend/core/db.mjs index 0b2bb8e05..271c21175 100644 --- a/backend/core/db.mjs +++ b/backend/core/db.mjs @@ -27,12 +27,12 @@ export default { /** * Initialize DB */ - async init (workerMode = false) { + async init(workerMode = false) { WIKI.logger.info('Checking DB configuration...') // Fetch DB Config - this.config = (process.env.DATABASE_URL) + this.config = process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } @@ -46,7 +46,11 @@ export default { // Handle SSL Options - let dbUseSSL = (WIKI.config.db.ssl === true || WIKI.config.db.ssl === 'true' || WIKI.config.db.ssl === 1 || WIKI.config.db.ssl === '1') + let dbUseSSL = + WIKI.config.db.ssl === true || + WIKI.config.db.ssl === 'true' || + WIKI.config.db.ssl === 1 || + WIKI.config.db.ssl === '1' let sslOptions = null if (dbUseSSL && isPlainObject(this.config) && WIKI.config.db?.sslOptions?.auto === false) { sslOptions = WIKI.config.db.sslOptions @@ -82,7 +86,7 @@ export default { } if (dbUseSSL && isPlainObject(this.config)) { - this.config.ssl = (sslOptions === true) ? { rejectUnauthorized: true } : sslOptions + this.config.ssl = sslOptions === true ? { rejectUnauthorized: true } : sslOptions } // Initialize Postgres Pool @@ -90,11 +94,15 @@ export default { this.pool = new Pool({ application_name: 'Wiki.js', ...this.config, - ...workerMode ? { min: 0, max: 1 } : WIKI.config.pool, + ...(workerMode ? { min: 0, max: 1 } : WIKI.config.pool), options: `-c search_path=${WIKI.config.db.schema}` }) - const db = drizzle({ client: this.pool, relations }) + const db = drizzle({ + client: this.pool, + relations, + ...(WIKI.config.dev?.logQueries && { logger: true }) + }) // Connect await this.connect(db) @@ -104,7 +112,9 @@ export default { const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true }) this.VERSION = dbVersion.version if (dbVersion.major < 16) { - WIKI.logger.error(`Your PostgreSQL database version (${dbVersion.major}) is too old and unsupported by Wiki.js. Requires >= 16. Exiting...`) + WIKI.logger.error( + `Your PostgreSQL database version (${dbVersion.major}) is too old and unsupported by Wiki.js. Requires >= 16. Exiting...` + ) process.exit(1) } WIKI.logger.info(`Using PostgreSQL v${dbVersion.version} [ OK ]`) @@ -125,7 +135,7 @@ export default { /** * Subscribe to database LISTEN / NOTIFY for multi-instances events */ - async subscribeToNotifications () { + async subscribeToNotifications() { let connSettings = this.knex.client.connectionSettings if (typeof connSettings === 'string') { const encodedName = encodeURIComponent(`Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`) @@ -138,15 +148,15 @@ export default { connSettings.application_name = `Wiki.js - ${WIKI.INSTANCE_ID}:PSUB` } this.listener = new PGPubSub(connSettings, { - log (ev) { + log(ev) { WIKI.logger.debug(ev) } }) // -> Outbound events handling - this.listener.addChannel('wiki', payload => { - if (('event' in payload) && payload.source !== WIKI.INSTANCE_ID) { + this.listener.addChannel('wiki', (payload) => { + if ('event' in payload && payload.source !== WIKI.INSTANCE_ID) { WIKI.logger.info(`Received event ${payload.event} from instance ${payload.source}: [ OK ]`) WIKI.events.inbound.emit(payload.event, payload.value) } @@ -164,7 +174,7 @@ export default { /** * Unsubscribe from database LISTEN / NOTIFY */ - async unsubscribeToNotifications () { + async unsubscribeToNotifications() { if (this.listener) { WIKI.events.outbound.offAny(this.notifyViaDB) WIKI.events.inbound.removeAllListeners() @@ -177,7 +187,7 @@ export default { * @param {string} event Event fired * @param {object} value Payload of the event */ - notifyViaDB (event, value) { + notifyViaDB(event, value) { WIKI.db.listener.publish('wiki', { source: WIKI.INSTANCE_ID, event, @@ -187,7 +197,7 @@ export default { /** * Attempt initial connection */ - async connect (db) { + async connect(db) { try { WIKI.logger.info('Connecting to database...') await db.execute('SELECT 1 + 1;') @@ -211,7 +221,7 @@ export default { /** * Migrate DB Schemas */ - async syncSchemas (db) { + async syncSchemas(db) { WIKI.logger.info('Ensuring DB schema exists...') await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`) WIKI.logger.info('Ensuring DB migrations have been applied...') diff --git a/backend/db/relations.mjs b/backend/db/relations.mjs index c9efc81ed..2a69aa6c0 100644 --- a/backend/db/relations.mjs +++ b/backend/db/relations.mjs @@ -1,16 +1,20 @@ import { defineRelations } from 'drizzle-orm' import * as schema from './schema.mjs' -export const relations = defineRelations(schema, - r => ({ - users: { - groups: r.many.groups({ - from: r.users.id.through(r.userGroups.userId), - to: r.groups.id.through(r.userGroups.groupId) - }) - }, - groups: { - members: r.many.users() - } - }) -) +export const relations = defineRelations(schema, (r) => ({ + users: { + groups: r.many.groups({ + from: r.users.id.through(r.userGroups.userId), + to: r.groups.id.through(r.userGroups.groupId) + }) + }, + groups: { + members: r.many.users() + }, + userKeys: { + user: r.one.users({ + from: r.userKeys.userId, + to: r.users.id + }) + } +})) diff --git a/backend/index.mjs b/backend/index.mjs index 8f7a37266..d34c0d0a2 100644 --- a/backend/index.mjs +++ b/backend/index.mjs @@ -49,6 +49,10 @@ const WIKI = { ROOTPATH: process.cwd(), INSTANCE_ID: nanoid(10), SERVERPATH: path.join(process.cwd(), 'backend'), + auth: { + groups: {}, + strategies: {} + }, configSvc, sites: {}, sitesMappings: {}, @@ -88,7 +92,7 @@ WIKI.logger.info(`Running node.js ${process.version} [ OK ]`) // Pre-Boot Sequence // ---------------------------------------- -async function preBoot () { +async function preBoot() { WIKI.dbManager = (await import('./core/db.mjs')).default WIKI.db = await dbManager.init() WIKI.models = (await import('./models/index.mjs')).default @@ -119,11 +123,12 @@ async function preBoot () { // Post-Boot Sequence // ---------------------------------------- -async function postBoot () { +async function postBoot() { await WIKI.models.locales.refreshFromDisk() await WIKI.models.authentication.refreshStrategiesFromDisk() + await WIKI.models.authentication.activateStrategies() await WIKI.models.locales.reloadCache() await WIKI.models.sites.reloadCache() } @@ -132,7 +137,7 @@ async function postBoot () { // Init HTTP Server // ---------------------------------------- -async function initHTTPServer () { +async function initHTTPServer() { // ---------------------------------------- // Load core modules // ---------------------------------------- @@ -147,9 +152,7 @@ async function initHTTPServer () { const app = fastify({ ajv: { - plugins: [ - [ajvFormats, {}] - ] + plugins: [[ajvFormats, {}]] }, bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb logger: { @@ -232,7 +235,7 @@ async function initHTTPServer () { }) // ---------------------------------------- - // Passport Authentication + // Sessions // ---------------------------------------- app.register(fastifyCookie, { @@ -244,18 +247,31 @@ async function initHTTPServer () { cookieName: 'wikiSession', cookie: { httpOnly: true, + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days secure: 'auto' }, saveUninitialized: false, store: { - get (sessionId, clb) { - + async get(sessionId, clb) { + try { + clb(null, await WIKI.models.sessions.get(sessionId)) + } catch (err) { + clb(err, null) + } }, - set (sessionId, sessionData, clb) { - + async set(sessionId, sessionData, clb) { + try { + clb(null, await WIKI.models.sessions.set(sessionId, sessionData)) + } catch (err) { + clb(err, null) + } }, - destroy (sessionId, clb) { - + async destroy(sessionId, clb) { + try { + clb(null, await WIKI.models.sessions.destroy(sessionId)) + } catch (err) { + clb(err, null) + } } } }) @@ -292,10 +308,7 @@ async function initHTTPServer () { } } }, - security: [ - { apiKeyAuth: [] }, - { bearerAuth: [] } - ] + security: [{ apiKeyAuth: [] }, { bearerAuth: [] }] } }) app.register(fastifySwaggerUi, { @@ -309,18 +322,18 @@ async function initHTTPServer () { app.addHook('preHandler', (req, reply, done) => { if (req.routeOptions.config?.permissions?.length > 0) { // Unauthenticated / No Permissions - if (!req.user?.isAuthenticated || !(req.user.permissions?.length > 0)) { + if (!req.session?.authenticated || !(req.session?.permissions?.length > 0)) { return reply.unauthorized() } // Is Root Admin? - if (!req.user.permissions.includes('manage:system')) { + if (!req.session.permissions.includes('manage:system')) { // Check for at least 1 permission - const isAllowed = req.routeOptions.config.permissions.some(perms => { + const isAllowed = req.routeOptions.config.permissions.some((perms) => { // Check for all permissions if (Array.isArray(perms)) { - return perms.every(perm => req.user.permissions?.some(p => p === perm)) + return perms.every((perm) => req.session.permissions?.some((p) => p === perm)) } else { - return req.user.permissions?.some(p => p === perms) + return req.session.permissions?.some((p) => p === perms) } }) // Forbidden diff --git a/backend/models/authentication.mjs b/backend/models/authentication.mjs index bc1a0db9a..dab5fdbc6 100644 --- a/backend/models/authentication.mjs +++ b/backend/models/authentication.mjs @@ -9,37 +9,88 @@ import { authentication as authenticationTable } from '../db/schema.mjs' * Authentication model */ class Authentication { - async getStrategy (module) { + async getStrategy(module) { return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module)) } - async getStrategies ({ enabledOnly = false } = {}) { - return WIKI.db.select().from(authenticationTable).where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined) + async getStrategies({ enabledOnly = false } = {}) { + return WIKI.db + .select() + .from(authenticationTable) + .where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined) } - async refreshStrategiesFromDisk () { + async refreshStrategiesFromDisk() { try { // -> Fetch definitions from disk - const authenticationDirs = await fs.readdir(path.join(WIKI.SERVERPATH, 'modules/authentication')) + const authenticationDirs = await fs.readdir( + path.join(WIKI.SERVERPATH, 'modules/authentication') + ) WIKI.data.authentication = [] for (const dir of authenticationDirs) { - const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), 'utf8') + const def = await fs.readFile( + path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), + 'utf8' + ) const defParsed = yaml.load(def) - if (!defParsed.isAvailable) { continue } + if (!defParsed.isAvailable) { + continue + } defParsed.key = dir defParsed.props = parseModuleProps(defParsed.props) WIKI.data.authentication.push(defParsed) WIKI.logger.debug(`Loaded authentication module definition ${dir} [ OK ]`) } - WIKI.logger.info(`Loaded ${WIKI.data.authentication.length} authentication module definitions [ OK ]`) + WIKI.logger.info( + `Loaded ${WIKI.data.authentication.length} authentication module definitions [ OK ]` + ) } catch (err) { - WIKI.logger.error('Failed to scan or load authentication providers [ FAILED ]') + WIKI.logger.error('Failed to scan or load authentication module definitions [ FAILED ]') WIKI.logger.error(err) } } - async init (ids) { + async activateStrategies() { + WIKI.logger.info('Activating authentication strategies...') + + // Unload any active strategies + try { + for (strKey in WIKI.auth.strategies) { + if (typeof WIKI.auth.strategies[strKey].destroy === 'function') { + await WIKI.auth.strategies[strKey].destroy() + } + } + } catch (err) { + WIKI.logger.warn(`Failed to unload active strategies [ FAILED ]`) + WIKI.logger.warn(err) + } + WIKI.auth.strategies = {} + + // Load enabled strategies + const enabledStrategies = await this.getStrategies({ enabledOnly: true }) + for (const stg of enabledStrategies) { + try { + const StrategyModule = ( + await import(`../modules/authentication/${stg.module}/authentication.mjs`) + ).default + WIKI.auth.strategies[stg.id] = new StrategyModule(stg.id, stg.config) + WIKI.auth.strategies[stg.id].module = stg.module + if (typeof WIKI.auth.strategies[stg.id].init === 'function') { + await WIKI.auth.strategies[stg.id].init() + } + + WIKI.logger.info(`Enabled authentication strategy ${stg.displayName} [ OK ]`) + } catch (err) { + WIKI.logger.error( + `Failed to enable authentication strategy ${stg.displayName} (${stg.id}) [ FAILED ]` + ) + WIKI.logger.error(err) + } + } + } + + async init(ids) { await WIKI.db.insert(authenticationTable).values({ id: ids.authModuleId, module: 'local', diff --git a/backend/models/index.mjs b/backend/models/index.mjs index 050869572..19c7d2e10 100644 --- a/backend/models/index.mjs +++ b/backend/models/index.mjs @@ -1,6 +1,7 @@ import { authentication } from './authentication.mjs' import { groups } from './groups.mjs' import { locales } from './locales.mjs' +import { sessions } from './sessions.mjs' import { settings } from './settings.mjs' import { sites } from './sites.mjs' import { users } from './users.mjs' @@ -9,6 +10,7 @@ export default { authentication, groups, locales, + sessions, settings, sites, users diff --git a/backend/models/sessions.mjs b/backend/models/sessions.mjs new file mode 100644 index 000000000..d69b1f913 --- /dev/null +++ b/backend/models/sessions.mjs @@ -0,0 +1,85 @@ +import { eq, sql } from 'drizzle-orm' +import { sessions as sessionsTable } from '../db/schema.mjs' + +/** + * Sessions model + */ +class Sessions { + /** + * Fetch all sessions from a single user + * + * @param {String} userId User ID + * @returns Promise User Sessions + */ + async getByUser(userId) { + return WIKI.db.select().from(sessionsTable).where(eq(sessionsTable.userId, userId)) + } + + /** + * Fetch a single session by id + * + * @param {String} id Session ID + * @returns Promise Session data + */ + async get(id) { + const res = await WIKI.db.select().from(sessionsTable).where(eq(sessionsTable.id, id)) + return res?.[0]?.data ?? null + } + + /** + * Set / Update a session + * + * @param {String} id Session ID + * @param {Object} data Session Data + */ + async set(id, data) { + await WIKI.db + .insert(sessionsTable) + .values([ + { + id, + userId: data?.user?.id ?? null, + data + } + ]) + .onConflictDoUpdate({ + target: sessionsTable.id, + set: { + data, + userId: data?.user?.id ?? null, + updatedAt: sql`now()` + } + }) + } + + /** + * Delete a session + * + * @param {String} id Session ID + * @returns Promise + */ + async destroy(id) { + return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.id, id)) + } + + /** + * Delete all sessions from all users + * + * @returns Promise + */ + async clearAllSessions() { + return WIKI.db.delete(sessionsTable) + } + + /** + * Delete all sessions from a single user + * + * @param {String} userId User ID + * @returns Promise + */ + async clearSessionsFromUser(userId) { + return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, userId)) + } +} + +export const sessions = new Sessions() diff --git a/backend/models/sites.mjs b/backend/models/sites.mjs index 2ea3aee03..a809761d8 100644 --- a/backend/models/sites.mjs +++ b/backend/models/sites.mjs @@ -7,14 +7,14 @@ import { eq } from 'drizzle-orm' * Sites model */ class Sites { - async getSiteById ({ id, forceReload = false }) { + async getSiteById({ id, forceReload = false }) { if (forceReload) { await WIKI.models.sites.reloadCache() } return WIKI.sites[id] } - async getSiteByHostname ({ hostname, forceReload = false }) { + async getSiteByHostname({ hostname, forceReload = false }) { if (forceReload) { await WIKI.models.sites.reloadCache() } @@ -25,10 +25,14 @@ class Sites { return null } - async reloadCache () { + async getAllSites() { + return WIKI.db.select().from(sitesTable).orderBy(sitesTable.hostname) + } + + async reloadCache() { WIKI.logger.info('Reloading site configurations...') const sites = await WIKI.db.select().from(sitesTable).orderBy(sitesTable.id) - WIKI.sites = keyBy(sites, s => s.id) + WIKI.sites = keyBy(sites, (s) => s.id) WIKI.sitesMappings = {} for (const site of sites) { WIKI.sitesMappings[site.hostname] = site.id @@ -36,105 +40,111 @@ class Sites { WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`) } - async createSite (hostname, config) { - const result = await WIKI.db.insert(sitesTable).values({ - hostname, - isEnabled: true, - config: toMerged({ - title: 'My Wiki Site', - description: '', - company: '', - contentLicense: '', - footerExtra: '', - pageExtensions: ['md', 'html', 'txt'], - pageCasing: true, - discoverable: false, - defaults: { - tocDepth: { - min: 1, - max: 2 - } - }, - features: { - browse: true, - ratings: false, - ratingsMode: 'off', - comments: false, - contributions: false, - profile: true, - search: true - }, - logoUrl: '', - logoText: true, - sitemap: true, - robots: { - index: true, - follow: true - }, - locales: { - primary: 'en', - active: ['en'] - }, - assets: { - logo: false, - logoExt: 'svg', - favicon: false, - faviconExt: 'svg', - loginBg: false - }, - theme: { - dark: false, - codeBlocksTheme: 'github-dark', - colorPrimary: '#1976D2', - colorSecondary: '#02C39A', - colorAccent: '#FF9800', - colorHeader: '#000000', - colorSidebar: '#1976D2', - injectCSS: '', - injectHead: '', - injectBody: '', - contentWidth: 'full', - sidebarPosition: 'left', - tocPosition: 'right', - showSharingMenu: true, - showPrintBtn: true, - baseFont: 'roboto', - contentFont: 'roboto' - }, - editors: { - asciidoc: { - isActive: true, - config: {} - }, - markdown: { - isActive: true, - config: { - allowHTML: true, - kroki: false, - krokiServerUrl: 'https://kroki.io', - latexEngine: 'katex', - lineBreaks: true, - linkify: true, - multimdTable: true, - plantuml: false, - plantumlServerUrl: 'https://www.plantuml.com/plantuml/', - quotes: 'english', - tabWidth: 2, - typographer: false, - underline: true + async createSite(hostname, config) { + const result = await WIKI.db + .insert(sitesTable) + .values({ + hostname, + isEnabled: true, + config: toMerged( + { + title: 'My Wiki Site', + description: '', + company: '', + contentLicense: '', + footerExtra: '', + pageExtensions: ['md', 'html', 'txt'], + pageCasing: true, + discoverable: false, + defaults: { + tocDepth: { + min: 1, + max: 2 + } + }, + features: { + browse: true, + ratings: false, + ratingsMode: 'off', + comments: false, + contributions: false, + profile: true, + search: true + }, + logoUrl: '', + logoText: true, + sitemap: true, + robots: { + index: true, + follow: true + }, + locales: { + primary: 'en', + active: ['en'] + }, + assets: { + logo: false, + logoExt: 'svg', + favicon: false, + faviconExt: 'svg', + loginBg: false + }, + theme: { + dark: false, + codeBlocksTheme: 'github-dark', + colorPrimary: '#1976D2', + colorSecondary: '#02C39A', + colorAccent: '#FF9800', + colorHeader: '#000000', + colorSidebar: '#1976D2', + injectCSS: '', + injectHead: '', + injectBody: '', + contentWidth: 'full', + sidebarPosition: 'left', + tocPosition: 'right', + showSharingMenu: true, + showPrintBtn: true, + baseFont: 'roboto', + contentFont: 'roboto' + }, + editors: { + asciidoc: { + isActive: true, + config: {} + }, + markdown: { + isActive: true, + config: { + allowHTML: true, + kroki: false, + krokiServerUrl: 'https://kroki.io', + latexEngine: 'katex', + lineBreaks: true, + linkify: true, + multimdTable: true, + plantuml: false, + plantumlServerUrl: 'https://www.plantuml.com/plantuml/', + quotes: 'english', + tabWidth: 2, + typographer: false, + underline: true + } + }, + wysiwyg: { + isActive: true, + config: {} + } + }, + uploads: { + conflictBehavior: 'overwrite', + normalizeFilename: true } }, - wysiwyg: { - isActive: true, - config: {} - } - }, - uploads: { - conflictBehavior: 'overwrite', - normalizeFilename: true - } - }, config) - }).returning({ id: sitesTable.id }) + config + ) + }) + .returning({ id: sitesTable.id }) const newSite = result[0] @@ -168,21 +178,21 @@ class Sites { return newSite } - async updateSite (id, patch) { + async updateSite(id, patch) { return WIKI.db.sites.query().findById(id).patch(patch) } - async deleteSite (id) { + async deleteSite(id) { // await WIKI.db.storage.query().delete().where('siteId', id) const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id)) return Boolean(deletedResult.rowCount > 0) } - async countSites () { + async countSites() { return WIKI.db.$count(sitesTable) } - async init (ids) { + async init(ids) { WIKI.logger.info('Inserting default site...') await WIKI.db.insert(sitesTable).values({ diff --git a/backend/models/users.mjs b/backend/models/users.mjs index 257bfb73e..f5efe801f 100644 --- a/backend/models/users.mjs +++ b/backend/models/users.mjs @@ -1,13 +1,19 @@ -/* global WIKI */ - import bcrypt from 'bcryptjs' -import { userGroups as userGroupsTable, users as usersTable } from '../db/schema.mjs' +import { userGroups, users as usersTable, userKeys } from '../db/schema.mjs' +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { DateTime } from 'luxon' +import { flatten, uniq } from 'es-toolkit/array' /** * Users model */ class Users { - async init (ids) { + async getByEmail(email) { + const res = await WIKI.db.select().from(usersTable).where(eq(usersTable.email, email)).limit(1) + return res?.[0] ?? null + } + async init(ids) { WIKI.logger.info('Inserting default users...') await WIKI.db.insert(usersTable).values([ @@ -60,7 +66,7 @@ class Users { } ]) - await WIKI.db.insert(userGroupsTable).values([ + await WIKI.db.insert(userGroups).values([ { userId: ids.userAdminId, groupId: ids.groupAdminId @@ -72,47 +78,246 @@ class Users { ]) } - async login ({ siteId, strategyId, username, password, ip }) { + async login({ siteId, strategyId, username, password, ip }, req) { if (strategyId in WIKI.auth.strategies) { - const selStrategy = WIKI.auth.strategies[strategyId] - if (!selStrategy.isEnabled) { - throw new Error('Inactive Strategy ID') + const str = WIKI.auth.strategies[strategyId] + const strInfo = WIKI.data.authentication.find((a) => a.key === str.module) + const context = { + ip, + siteId, + ...(strInfo.useForm && { + username, + password + }) } - const strInfo = WIKI.data.authentication.find(a => a.key === selStrategy.module) + // Authenticate + const user = await str.authenticate(context) - // Inject form user/pass - if (strInfo.useForm) { - set(context.req, 'body.email', username) - set(context.req, 'body.password', password) - set(context.req.params, 'strategy', strategyId) - } + // Perform post-login checks + return this.afterLoginChecks( + user, + strategyId, + context, + { + skipTFA: !strInfo.useForm, + skipChangePwd: !strInfo.useForm + }, + req + ) + } else { + throw new Error('Invalid Strategy ID') + } + } - // Authenticate - return new Promise((resolve, reject) => { - WIKI.auth.passport.authenticate(selStrategy.id, { - session: !strInfo.useForm, - scope: strInfo.scopes ? strInfo.scopes : null - }, async (err, user, info) => { - if (err) { return reject(err) } - if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) } - - try { - const resp = await WIKI.db.users.afterLoginChecks(user, selStrategy.id, context, { - siteId, - skipTFA: !strInfo.useForm, - skipChangePwd: !strInfo.useForm - }) - resolve(resp) - } catch (err) { - reject(err) + async afterLoginChecks( + user, + strategyId, + context, + { skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false }, + req + ) { + const str = WIKI.auth.strategies[strategyId] + if (!str) { + throw new Error('ERR_INVALID_STRATEGY') + } + + // Get user groups + user.groups = await WIKI.db.query.users + .findFirst({ + columns: {}, + where: { + id: user.id + }, + with: { + groups: { + columns: { + id: true, + permissions: true, + redirectOnLogin: true + } } - })(context.req, context.res, () => {}) + } }) + .then((r) => r?.groups || []) + + // Get redirect target + let redirect = '/' + if (user.groups && user.groups.length > 0) { + for (const grp of user.groups) { + if (grp.redirectOnLogin && grp.redirectOnLogin !== '/') { + redirect = grp.redirectOnLogin + break + } + } + } + + // Get auth strategy flags + const authStr = user.auth[strategyId] || {} + + // Is 2FA required? + if (!skipTFA) { + if (authStr.tfaIsActive && authStr.tfaSecret) { + try { + const tfaToken = await WIKI.db.userKeys.generateToken({ + kind: 'tfa', + userId: user.id, + meta: { + strategyId + } + }) + return { + nextAction: 'provideTfa', + continuationToken: tfaToken, + redirect + } + } catch (errc) { + WIKI.logger.warn(errc) + throw new WIKI.Error.AuthGenericError() + } + } else if (str.config?.enforceTfa || authStr.tfaRequired) { + try { + const tfaQRImage = await user.generateTFA(strategyId, context.siteId) + const tfaToken = await WIKI.db.userKeys.generateToken({ + kind: 'tfaSetup', + userId: user.id, + meta: { + strategyId + } + }) + return { + nextAction: 'setupTfa', + continuationToken: tfaToken, + tfaQRImage, + redirect + } + } catch (errc) { + WIKI.logger.warn(errc) + throw new WIKI.Error.AuthGenericError() + } + } + } + + // Must Change Password? + if (!skipChangePwd && authStr.mustChangePwd) { + try { + const pwdChangeToken = await this.generateToken({ + kind: 'changePwd', + userId: user.id, + meta: { + strategyId + } + }) + + return { + nextAction: 'changePassword', + continuationToken: pwdChangeToken, + redirect + } + } catch (errc) { + WIKI.logger.warn(errc) + throw new WIKI.Error.AuthGenericError() + } + } + + // Set Session Data + this.updateSession(user, req) + + return { + authenticated: true, + nextAction: 'redirect', + redirect + } + } + + async loginChangePassword({ strategyId, siteId, continuationToken, newPassword, ip }, req) { + if (!newPassword || newPassword.length < 8) { + throw new Error('ERR_PASSWORD_TOO_SHORT') + } + const { user, strategyId: expectedStrategyId } = await this.validateToken({ + kind: 'changePwd', + token: continuationToken + }) + + if (strategyId !== expectedStrategyId) { + throw new Error('ERR_INVALID_STRATEGY') + } + + if (user) { + user.auth[strategyId].password = await bcrypt.hash(newPassword, 12) + user.auth[strategyId].mustChangePwd = false + await WIKI.db.update(usersTable).set({ auth: user.auth }).where(eq(usersTable.id, user.id)) + + return this.afterLoginChecks( + user, + strategyId, + { ip, siteId }, + { skipChangePwd: true, skipTFA: true }, + req + ) } else { - throw new Error('Invalid Strategy ID') + throw new Error('ERR_INVALID_USER') } } + + updateSession(user, req) { + req.session.authenticated = true + req.session.user = { + id: user.id, + email: user.email, + name: user.name, + hasAvatar: user.hasAvatar, + timezone: user.prefs?.timezone, + dateFormat: user.prefs?.dateFormat, + timeFormat: user.prefs?.timeFormat, + appearance: user.prefs?.appearance, + cvd: user.prefs?.cvd + } + req.session.permissions = uniq(flatten(user.groups?.map((g) => g.permissions))) + } + + async generateToken({ userId, kind, meta = {} }) { + WIKI.logger.debug(`Generating ${kind} token for user ${userId}...`) + const token = await nanoid() + await WIKI.db.insert(userKeys).values({ + kind, + token, + meta, + validUntil: DateTime.utc().plus({ days: 1 }).toISO(), + userId + }) + return token + } + + async validateToken({ kind, token, skipDelete }) { + const res = await WIKI.db.query.userKeys.findFirst({ + where: { + kind, + token + }, + with: { + user: true + } + }) + if (res) { + if (skipDelete !== true) { + await WIKI.db.delete(userKeys).where(eq(userKeys.id, res.id)) + } + if (DateTime.utc() > DateTime.fromISO(res.validUntil)) { + throw new Error('ERR_EXPIRED_VALIDATION_TOKEN') + } + return { + ...res.meta, + user: res.user + } + } else { + throw new Error('ERR_INVALID_VALIDATION_TOKEN') + } + } + + async destroyToken({ token }) { + return WIKI.db.delete(userKeys).where(eq(userKeys.token, token)) + } } export const users = new Users() diff --git a/backend/modules/authentication/local/authentication.mjs b/backend/modules/authentication/local/authentication.mjs index 71af987ed..08d6451dc 100644 --- a/backend/modules/authentication/local/authentication.mjs +++ b/backend/modules/authentication/local/authentication.mjs @@ -4,42 +4,31 @@ import bcrypt from 'bcryptjs' // ------------------------------------ // Local Account // ------------------------------------ +export default class LocalAuthentication { + constructor(strategyId, conf) { + this.strategyId = strategyId + this.conf = conf + } -import { Strategy } from 'passport-local' - -export default { - init (passport, strategyId, conf) { - passport.use(strategyId, - new Strategy({ - usernameField: 'email', - passwordField: 'password' - }, async (uEmail, uPassword, done) => { - try { - const user = await WIKI.db.users.query().findOne({ - email: uEmail.toLowerCase() - }) - if (user) { - const authStrategyData = user.auth[strategyId] - if (!authStrategyData) { - throw new Error('ERR_INVALID_STRATEGY') - } else if (await bcrypt.compare(uPassword, authStrategyData.password) !== true) { - throw new Error('ERR_LOGIN_FAILED') - } else if (!user.isActive) { - throw new Error('ERR_INACTIVE_USER') - } else if (authStrategyData.restrictLogin) { - throw new Error('ERR_LOGIN_RESTRICTED') - } else if (!user.isVerified) { - throw new Error('ERR_USER_NOT_VERIFIED') - } else { - done(null, user) - } - } else { - throw new Error('ERR_LOGIN_FAILED') - } - } catch (err) { - done(err, null) - } - }) - ) + async authenticate({ username, password }) { + const user = await WIKI.models.users.getByEmail(username.toLowerCase()) + if (user) { + const authStrategyData = user.auth[this.strategyId] + if (!authStrategyData) { + throw new Error('ERR_INVALID_STRATEGY') + } else if ((await bcrypt.compare(password, authStrategyData.password)) !== true) { + throw new Error('ERR_LOGIN_FAILED') + } else if (!user.isActive) { + throw new Error('ERR_INACTIVE_USER') + } else if (authStrategyData.restrictLogin) { + throw new Error('ERR_LOGIN_RESTRICTED') + } else if (!user.isVerified) { + throw new Error('ERR_USER_NOT_VERIFIED') + } else { + return user + } + } else { + throw new Error('ERR_LOGIN_FAILED') + } } } diff --git a/backend/package-lock.json b/backend/package-lock.json index 97d432514..1ae937dbc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -29,6 +29,7 @@ "es-toolkit": "1.45.1", "fastify": "5.7.1", "fastify-favicon": "5.0.0", + "filesize": "11.0.13", "js-yaml": "4.1.1", "luxon": "3.7.2", "mime": "4.1.0", @@ -3124,6 +3125,15 @@ "reusify": "^1.0.4" } }, + "node_modules/filesize": { + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.13.tgz", + "integrity": "sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.8.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index e59d19d07..63c372ba7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -58,6 +58,7 @@ "es-toolkit": "1.45.1", "fastify": "5.7.1", "fastify-favicon": "5.0.0", + "filesize": "11.0.13", "js-yaml": "4.1.1", "luxon": "3.7.2", "mime": "4.1.0", diff --git a/config.sample.yml b/config.sample.yml index ced4fbdea..9ca2639d7 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -105,5 +105,7 @@ scheduler: # Settings when running in dev mode only dev: + dropSchema: false + logQueries: false port: 3001 hmrClientPort: 3001 diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json deleted file mode 100644 index 518e8c95e..000000000 --- a/frontend/.oxfmtrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "semi": false, - "singleQuote": true, - "trailingComma": "none", - "bracketSameLine": true, - "endOfLine": "lf", - "insertFinalNewline": true -} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 269b3b683..504d0a779 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -124,9 +124,9 @@ router.beforeEach(async (to, from) => { commonStore.routerLoading = true // -> Init Auth Token - if (userStore.token && !userStore.authenticated) { - userStore.loadToken() - } + // if (userStore.token && !userStore.authenticated) { + // userStore.loadToken() + // } // -> System Flags if (!flagsStore.loaded) { @@ -147,8 +147,8 @@ router.beforeEach(async (to, from) => { applyLocale(commonStore.desiredLocale) // -> User Profile - if (userStore.authenticated && !userStore.profileLoaded) { - console.info(`Refreshing user ${userStore.id} profile...`) + if (!userStore.profileLoaded) { + console.info(`Refreshing user profile...`) await userStore.refreshProfile() } diff --git a/frontend/src/boot/api.js b/frontend/src/boot/api.js index 57d7fd0b4..fd6ebf7a7 100644 --- a/frontend/src/boot/api.js +++ b/frontend/src/boot/api.js @@ -2,7 +2,7 @@ import ky from 'ky' import { useUserStore } from '@/stores/user' -export function initializeApi (store) { +export function initializeApi(store) { const userStore = useUserStore(store) let refreshPromise = null @@ -10,7 +10,7 @@ export function initializeApi (store) { const client = ky.create({ prefixUrl: '/_api', - credentials: 'omit', + credentials: 'same-origin', hooks: { beforeRequest: [ async (request) => { @@ -24,7 +24,7 @@ export function initializeApi (store) { if (!userStore.isTokenValid({ minutes: 1 })) { if (!fetching) { refreshPromise = new Promise((resolve, reject) => { - (async () => { + ;(async () => { fetching = true try { await userStore.refreshToken() diff --git a/frontend/src/components/AuthLoginPanel.vue b/frontend/src/components/AuthLoginPanel.vue index 52b3d2e7b..773103eb8 100644 --- a/frontend/src/components/AuthLoginPanel.vue +++ b/frontend/src/components/AuthLoginPanel.vue @@ -556,7 +556,6 @@ async function handleLoginResponse (resp) { $q.loading.show({ message: t('auth.loginSuccess') }) - Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' }) setTimeout(() => { const loginRedirect = Cookies.get('loginRedirect') if (loginRedirect === '/' && resp.redirect) { @@ -595,20 +594,22 @@ async function login () { if (!isFormValid) { throw new Error(t('auth.errors.login')) } - const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/login`, { + const resp = await API_CLIENT.put(`sites/${siteStore.id}/auth/login`, { json: { strategyId: state.selectedStrategyId, username: state.username, password: state.password - } + }, + throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 }).json() - if (resp.operation?.succeeded) { + if (resp.ok) { state.password = '' handleLoginResponse(resp) } else { - throw new Error(resp.operation?.message || t('auth.errors.loginError')) + throw new Error(resp.message || t('auth.errors.loginError')) } } catch (err) { + console.warn(err) $q.loading.hide() $q.notify({ type: 'negative', @@ -778,48 +779,23 @@ async function changePwd () { if (!isFormValid) { throw new Error(t('auth.errors.register')) } - const resp = await APOLLO_CLIENT.mutate({ - mutation: ` - mutation ( - $continuationToken: String - $newPassword: String! - $strategyId: UUID! - $siteId: UUID! - ) { - changePassword ( - continuationToken: $continuationToken - newPassword: $newPassword - strategyId: $strategyId - siteId: $siteId - ) { - operation { - succeeded - message - } - jwt - nextAction - continuationToken - redirect - tfaQRImage - } - } - `, - variables: { - continuationToken: state.continuationToken, - newPassword: state.newPassword, + const resp = await API_CLIENT.put(`sites/${siteStore.id}/auth/changePassword`, { + json: { strategyId: state.selectedStrategyId, - siteId: siteStore.id - } - }) - if (resp.data?.changePassword?.operation?.succeeded) { + continuationToken: state.continuationToken, + newPassword: state.newPassword + }, + throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 + }).json() + if (resp.ok) { state.password = '' $q.notify({ type: 'positive', message: t('auth.changePwd.success') }) - await handleLoginResponse(resp.data.changePassword) + await handleLoginResponse(resp) } else { - throw new Error(resp.data?.changePassword?.operation?.message || t('auth.errors.loginError')) + throw new Error(resp.message || t('auth.errors.loginError')) } } catch (err) { $q.notify({ diff --git a/frontend/src/pages/AdminSystem.vue b/frontend/src/pages/AdminSystem.vue index 84171bc18..d9f73f325 100644 --- a/frontend/src/pages/AdminSystem.vue +++ b/frontend/src/pages/AdminSystem.vue @@ -260,7 +260,7 @@ const platformLogo = computed(() => { case 'darwin': return 'apple-logo' case 'linux': - if (this.info.operatingSystem.indexOf('Ubuntu') >= 0) { + if (state.info.operatingSystem.indexOf('Ubuntu') >= 0) { return 'ubuntu' } else { return 'linux' @@ -292,30 +292,7 @@ const clientViewport = computed(() => { async function load () { state.loading++ $q.loading.show() - const resp = await APOLLO_CLIENT.query({ - query: ` - query getSystemInfo { - systemInfo { - configFile - cpuCores - currentVersion - dbHost - dbVersion - hostname - latestVersion - latestVersionReleaseDate - nodeVersion - operatingSystem - platform - ramTotal - upgradeCapable - workingDirectory - } - } - `, - fetchPolicy: 'network-only' - }) - state.info = cloneDeep(resp?.data?.systemInfo) + state.info = await API_CLIENT.get('system/info').json() $q.loading.hide() state.loading-- } diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index fd368209e..1e00ab118 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -3,8 +3,6 @@ import { defineStore } from 'pinia' import { clone, cloneDeep, sortBy } from 'lodash-es' import semverGte from 'semver/functions/gte' -/* global APOLLO_CLIENT */ - export const useAdminStore = defineStore('admin', { state: () => ({ currentSiteId: null, @@ -23,80 +21,41 @@ export const useAdminStore = defineStore('admin', { overlay: null, overlayOpts: {}, sites: [], - locales: [ - { code: 'en', name: 'English' } - ] + locales: [{ code: 'en', name: 'English' }] }), getters: { isVersionLatest: (state) => { - if (!state.info.currentVersion || !state.info.latestVersion || state.info.currentVersion === 'n/a' || state.info.latestVersion === 'n/a') { + if ( + !state.info.currentVersion || + !state.info.latestVersion || + state.info.currentVersion === 'n/a' || + state.info.latestVersion === 'n/a' + ) { return false } return semverGte(state.info.currentVersion, state.info.latestVersion) } }, actions: { - async fetchLocales () { - const resp = await APOLLO_CLIENT.query({ - query: ` - query getAdminLocales { - locales { - code - language - name - nativeName - } - } - ` - }) - this.locales = sortBy(cloneDeep(resp?.data?.locales ?? []), ['nativeName', 'name']) + async fetchLocales() { + const resp = await API_CLIENT.get('locales').json() + this.locales = sortBy(cloneDeep(resp ?? []), ['nativeName', 'name']) }, - async fetchInfo () { - const resp = await APOLLO_CLIENT.query({ - query: ` - query getAdminInfo { - apiState - metricsState - systemInfo { - groupsTotal - tagsTotal - usersTotal - loginsPastDay - currentVersion - latestVersion - isMailConfigured - isSchedulerHealthy - } - } - `, - fetchPolicy: 'network-only' - }) - this.info.groupsTotal = clone(resp?.data?.systemInfo?.groupsTotal ?? 0) - this.info.tagsTotal = clone(resp?.data?.systemInfo?.tagsTotal ?? 0) - this.info.usersTotal = clone(resp?.data?.systemInfo?.usersTotal ?? 0) - this.info.loginsPastDay = clone(resp?.data?.systemInfo?.loginsPastDay ?? 0) - this.info.currentVersion = clone(resp?.data?.systemInfo?.currentVersion ?? 'n/a') - this.info.latestVersion = clone(resp?.data?.systemInfo?.latestVersion ?? 'n/a') - this.info.isApiEnabled = clone(resp?.data?.apiState ?? false) - this.info.isMetricsEnabled = clone(resp?.data?.metricsState ?? false) - this.info.isMailConfigured = clone(resp?.data?.systemInfo?.isMailConfigured ?? false) - this.info.isSchedulerHealthy = clone(resp?.data?.systemInfo?.isSchedulerHealthy ?? false) + async fetchInfo() { + const resp = await API_CLIENT.get('system/info').json() + this.info.groupsTotal = clone(resp?.groupsTotal ?? 0) + this.info.tagsTotal = clone(resp?.tagsTotal ?? 0) + this.info.usersTotal = clone(resp?.usersTotal ?? 0) + this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0) + this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a') + this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a') + this.info.isApiEnabled = clone(resp?.apiState ?? false) + this.info.isMetricsEnabled = clone(resp?.metricsState ?? false) + this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false) + this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false) }, - async fetchSites () { - const resp = await APOLLO_CLIENT.query({ - query: ` - query getSites { - sites { - id - hostname - isEnabled - title - } - } - `, - fetchPolicy: 'network-only' - }) - this.sites = cloneDeep(resp?.data?.sites ?? []) + async fetchSites() { + this.sites = (await API_CLIENT.get('sites').json()) ?? [] if (!this.currentSiteId) { this.currentSiteId = this.sites[0].id } diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 2b3c35925..cdb37ed87 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -21,10 +21,7 @@ export const useUserStore = defineStore('user', { cvd: 'none', permissions: [], pagePermissions: [], - iat: 0, - exp: null, authenticated: false, - token: Cookies.get('jwt'), profileLoaded: false }), getters: { @@ -40,162 +37,93 @@ export const useUserStore = defineStore('user', { } }, actions: { - isTokenValid (offset) { - return this.exp && this.exp > (offset ? DateTime.now().plus(offset) : DateTime.now()) - }, - loadToken () { - if (!this.token) { return } + async refreshProfile() { try { - const jwtData = jwtDecode(this.token) - this.id = jwtData.id - this.email = jwtData.email - this.iat = jwtData.iat - this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' }) - if (this.exp > DateTime.utc()) { - this.authenticated = true + const resp = await API_CLIENT.get('users/whoami', { + cache: 'no-store' + }).json() + if (!resp || !resp.authenticated) { + this.setToGuest() } else { - console.info('Token has expired and will be refreshed on next query.') - } - } catch (err) { - console.warn('Failed to parse JWT. Invalid or malformed.') - } - }, - async refreshToken () { - try { - const respRaw = await APOLLO_CLIENT.mutate({ - context: { - skipAuth: true - }, - mutation: ` - mutation refreshToken ( - $token: String! - ) { - refreshToken(token: $token) { - operation { - succeeded - message - } - jwt - } - } - `, - variables: { - token: this.token - } - }) - const resp = respRaw?.data?.refreshToken ?? {} - if (!resp.operation?.succeeded) { - throw new Error(resp.operation?.message || 'Failed to refresh token.') + this.$patch({ + name: resp.name || 'Unknown User', + email: resp.email, + hasAvatar: resp.hasAvatar ?? false, + location: resp.location || '', + jobTitle: resp.jobTitle || '', + pronouns: resp.pronouns || '', + timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '', + dateFormat: resp.dateFormat || '', + timeFormat: resp.timeFormat || '12h', + appearance: resp.appearance || 'site', + cvd: resp.cvd || 'none', + permissions: resp.permissions || [], + authenticated: true, + profileLoaded: true + }) } - Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' }) - this.token = resp.jwt - this.loadToken() - return true } catch (err) { console.warn(err) - return false } }, - async refreshProfile () { - if (!this.authenticated || !this.id) { - return - } - try { - const respRaw = await APOLLO_CLIENT.query({ - query: ` - query refreshProfile ( - $id: UUID! - ) { - userById(id: $id) { - id - name - email - hasAvatar - meta - prefs - lastLoginAt - groups { - id - name - } - } - userPermissions - } - `, - variables: { - id: this.id - } - }) - const resp = respRaw?.data?.userById - if (!resp || resp.id !== this.id) { - throw new Error('Failed to fetch user profile!') - } - this.name = resp.name || 'Unknown User' - this.email = resp.email - this.hasAvatar = resp.hasAvatar ?? false - this.location = resp.meta.location || '' - this.jobTitle = resp.meta.jobTitle || '' - this.pronouns = resp.meta.pronouns || '' - this.timezone = resp.prefs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '' - this.dateFormat = resp.prefs.dateFormat || '' - this.timeFormat = resp.prefs.timeFormat || '12h' - this.appearance = resp.prefs.appearance || 'site' - this.cvd = resp.prefs.cvd || 'none' - this.permissions = respRaw.data.userPermissions || [] - this.profileLoaded = true - } catch (err) { - console.warn(err) - } + async logout() { + const siteStore = useSiteStore() + await API_CLIENT.get(`sites/${siteStore.id}/auth/logout`).json() + this.setToGuest() + EVENT_BUS.emit('logout') }, - logout () { - Cookies.remove('jwt', { path: '/' }) + setToGuest() { this.$patch({ id: '10000000-0000-4000-8000-000000000001', email: '', name: '', hasAvatar: false, - localeCode: '', timezone: '', dateFormat: 'YYYY-MM-DD', timeFormat: '12h', appearance: 'site', cvd: 'none', permissions: [], - iat: 0, - exp: null, authenticated: false, - token: '', profileLoaded: false }) - EVENT_BUS.emit('logout') }, - getAccessibleColor (base, hexBase) { + getAccessibleColor(base, hexBase) { return getAccessibleColor(base, hexBase, this.cvd) }, - can (permission) { - if (this.permissions.includes('manage:system') || this.permissions.includes(permission) || this.pagePermissions.includes(permission)) { + can(permission) { + if ( + this.permissions.includes('manage:system') || + this.permissions.includes(permission) || + this.pagePermissions.includes(permission) + ) { return true } return false }, - async fetchPagePermissions (path) { + async fetchPagePermissions(path) { if (path.startsWith('/_')) { this.pagePermissions = [] return } const siteStore = useSiteStore() try { - this.pagePermissions = await API_CLIENT.post(`sites/${siteStore.id}/pages/userPermissions`, { - json: { - path + this.pagePermissions = await API_CLIENT.post( + `sites/${siteStore.id}/pages/userPermissions`, + { + json: { + path + } } - }).json() + ).json() } catch (err) { console.warn(`Failed to fetch page permissions at path ${path}!`) } }, - formatDateTime (t, date) { - return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat(t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat })) + formatDateTime(t, date) { + return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat( + t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat }) + ) } } })