refactor: auth + sessions

pull/7976/head
NGPixel 6 months ago
parent dc78af9156
commit 68e6a2787a
No known key found for this signature in database

@ -1,5 +1,5 @@
{ {
"$schema": "./node_modules/oxfmt/configuration_schema.json", "$schema": "./backend/node_modules/oxfmt/configuration_schema.json",
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",

@ -1,14 +1,14 @@
{ {
"eslint.enable": false, "eslint.enable": false,
"editor.formatOnSave": false,
"editor.tabSize": 2, "editor.tabSize": 2,
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": ["backend/locales"],
"backend/locales",
],
"i18n-ally.pathMatcher": "{locale}.json", "i18n-ally.pathMatcher": "{locale}.json",
"i18n-ally.keystyle": "flat", "i18n-ally.keystyle": "flat",
"i18n-ally.sortKeys": true, "i18n-ally.sortKeys": true,
"i18n-ally.enabledFrameworks": [ "i18n-ally.enabledFrameworks": ["vue"],
"vue" "[javascript]": {
] "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
}
} }

@ -1,12 +1,13 @@
/** /**
* Authentication API Routes * Authentication API Routes
*/ */
async function routes (app, options) { async function routes(app, options) {
/** /**
* GET SITE AUTHENTICATION STRATEGIES * GET SITE AUTHENTICATION STRATEGIES
*/ */
app.get('/sites/:siteId/auth/strategies', { app.get(
'/sites/:siteId/auth/strategies',
{
schema: { schema: {
summary: 'List all site authentication strategies', summary: 'List all site authentication strategies',
tags: ['Authentication'], tags: ['Authentication'],
@ -29,12 +30,17 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.badRequest('Invalid Site ID')
}
const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true }) const activeStrategies = await WIKI.models.authentication.getStrategies({ enabledOnly: true })
const siteStrategies = activeStrategies.map(str => { const siteStrategies = activeStrategies
const authModule = WIKI.data.authentication.find(m => m.key === str.module) .map((str) => {
const siteStr = site.config.authStrategies.find(s => s.id === str.id) || {} const authModule = WIKI.data.authentication.find((m) => m.key === str.module)
const siteStr = site.config.authStrategies.find((s) => s.id === str.id) || {}
return { return {
id: str.id, id: str.id,
displayName: str.displayName, displayName: str.displayName,
@ -45,14 +51,18 @@ async function routes (app, options) {
order: siteStr.order ?? 0, order: siteStr.order ?? 0,
isVisible: siteStr.isVisible ?? false isVisible: siteStr.isVisible ?? false
} }
}).sort((a,b) => a.order - b.order)
return req.query.visibleOnly ? siteStrategies.filter(s => s.isVisible) : siteStrategies
}) })
.sort((a, b) => a.order - b.order)
return req.query.visibleOnly ? siteStrategies.filter((s) => s.isVisible) : siteStrategies
}
)
/** /**
* LOGIN USING USER/PASS * LOGIN USING USER/PASS
*/ */
app.post('/sites/:siteId/auth/login', { app.put(
'/sites/:siteId/auth/login',
{
schema: { schema: {
summary: 'Login', summary: 'Login',
tags: ['Authentication'], tags: ['Authentication'],
@ -67,7 +77,7 @@ async function routes (app, options) {
}, },
body: { body: {
type: 'object', type: 'object',
required: ['strategyId', 'username', 'password'], required: ['strategyId'],
properties: { properties: {
strategyId: { strategyId: {
type: 'string', type: 'string',
@ -86,15 +96,109 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
return WIKI.models.users.login({ async (req, reply) => {
try {
const result = await WIKI.models.users.login(
{
siteId: req.params.siteId, siteId: req.params.siteId,
strategyId: req.body.strategyId, strategyId: req.body.strategyId,
username: req.body.username, username: req.body.username,
password: req.body.password, password: req.body.password,
ip: req.ip ip: req.ip
}) },
}) 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')
}
}
}
)
} }
export default routes export default routes

@ -1,8 +1,10 @@
/** /**
* Pages API Routes * Pages API Routes
*/ */
async function routes (app, options) { async function routes(app, options) {
app.get('/sites/:siteId/pages', { app.get(
'/sites/:siteId/pages',
{
schema: { schema: {
summary: 'List all pages', summary: 'List all pages',
tags: ['Pages'], tags: ['Pages'],
@ -16,11 +18,15 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
async (req, reply) => {
return [] return []
}) }
)
app.get('/sites/:siteId/pages/:pageIdOrHash', { app.get(
'/sites/:siteId/pages/:pageIdOrHash',
{
schema: { schema: {
summary: 'List all pages', summary: 'List all pages',
tags: ['Pages'], tags: ['Pages'],
@ -33,10 +39,7 @@ async function routes (app, options) {
}, },
pageIdOrHash: { pageIdOrHash: {
type: 'string', type: 'string',
oneOf: [ oneOf: [{ format: 'uuid' }, { pattern: '^[a-f0-9]+$' }]
{ format: 'uuid' },
{ pattern: '^[a-f0-9]+$' }
]
} }
} }
}, },
@ -50,11 +53,15 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
async (req, reply) => {
return [] return []
}) }
)
app.post('/sites/:siteId/pages/userPermissions', { app.post(
'/sites/:siteId/pages/userPermissions',
{
schema: { schema: {
summary: 'Get page user permissions', summary: 'Get page user permissions',
tags: ['Pages'], tags: ['Pages'],
@ -84,9 +91,11 @@ async function routes (app, options) {
] ]
} }
} }
}, async (req, reply) => { },
async (req, reply) => {
return [] return []
}) }
)
} }
export default routes export default routes

@ -3,17 +3,33 @@ import { validate as uuidValidate } from 'uuid'
/** /**
* Sites API Routes * Sites API Routes
*/ */
async function routes (app, options) { async function routes(app, options) {
app.get('/', { app.get(
'/',
{
config: {
permissions: ['read:sites', 'read:dashboard']
},
schema: { schema: {
summary: 'List all sites', summary: 'List all sites',
tags: ['Sites'] tags: ['Sites']
} }
}, async (req, reply) => { },
return { hello: 'world' } 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(', ')
}))
}
)
app.get('/:siteIdorHostname', { app.get(
'/:siteIdorHostname',
{
schema: { schema: {
summary: 'Get site info', summary: 'Get site info',
tags: ['Sites'], tags: ['Sites'],
@ -23,17 +39,14 @@ async function routes (app, options) {
siteId: { siteId: {
type: 'string', type: 'string',
description: 'Either a site ID, hostname or "current" to use the request hostname.', description: 'Either a site ID, hostname or "current" to use the request hostname.',
oneOf: [ oneOf: [{ format: 'uuid' }, { enum: ['current'] }, { pattern: '^[a-f0-9]+$' }]
{ format: 'uuid' },
{ enum: ['current'] },
{ pattern: '^[a-f0-9]+$' }
]
} }
}, },
required: ['siteIdorHostname'] required: ['siteIdorHostname']
},
} }
}, async (req, reply) => { }
},
async (req, reply) => {
let site let site
if (req.params.siteId === 'current' && req.hostname) { if (req.params.siteId === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
@ -50,12 +63,15 @@ async function routes (app, options) {
isEnabled: site.isEnabled isEnabled: site.isEnabled
} }
: null : null
}) }
)
/** /**
* CREATE SITE * CREATE SITE
*/ */
app.post('/', { app.post(
'/',
{
config: { config: {
// permissions: ['create:sites', 'manage:sites'] // permissions: ['create:sites', 'manage:sites']
}, },
@ -101,18 +117,24 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
const result = await WIKI.models.sites.createSite(req.body.hostname, { title: req.body.title }) async (req, reply) => {
const result = await WIKI.models.sites.createSite(req.body.hostname, {
title: req.body.title
})
return { return {
message: 'Site created successfully.', message: 'Site created successfully.',
id: result.id id: result.id
} }
}) }
)
/** /**
* UPDATE SITE * UPDATE SITE
*/ */
app.put('/:siteId', { app.put(
'/:siteId',
{
config: { config: {
permissions: ['manage:sites'] permissions: ['manage:sites']
}, },
@ -120,14 +142,18 @@ async function routes (app, options) {
summary: 'Update a site', summary: 'Update a site',
tags: ['Sites'] tags: ['Sites']
} }
}, async (req, reply) => { },
async (req, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
/** /**
* DELETE SITE * DELETE SITE
*/ */
app.delete('/:siteId', { app.delete(
'/:siteId',
{
config: { config: {
permissions: ['manage:sites'] permissions: ['manage:sites']
}, },
@ -150,9 +176,10 @@ async function routes (app, options) {
} }
} }
} }
}, async (req, reply) => { },
async (req, reply) => {
try { try {
if (await WIKI.models.sites.countSites() <= 1) { if ((await WIKI.models.sites.countSites()) <= 1) {
reply.conflict('Cannot delete the last site. At least 1 site must exist at all times.') 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)) { } else if (await WIKI.models.sites.deleteSite(req.params.siteId)) {
reply.code(204) reply.code(204)
@ -162,7 +189,8 @@ async function routes (app, options) {
} catch (err) { } catch (err) {
reply.send(err) reply.send(err)
} }
}) }
)
} }
export default routes export default routes

@ -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 * System API Routes
*/ */
async function routes (app, options) { async function routes(app, options) {
app.get('/info', { app.get(
'/info',
{
config: {
permissions: ['read:dashboard', 'manage:sites']
},
schema: { schema: {
summary: 'System Info', summary: 'System Info',
tags: ['System'] tags: ['System']
} }
}, async (request, reply) => { },
return { hello: 'world' } 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()
}
}
)
app.get('/flags', { app.get(
'/flags',
{
schema: { schema: {
summary: 'System Flags', summary: 'System Flags',
tags: ['System'] tags: ['System']
} }
}, async (request, reply) => { },
return { hello: 'world' } async (request, reply) => {
}) return WIKI.config.flags
}
)
} }
export default routes export default routes

@ -1,51 +1,95 @@
/** /**
* Users API Routes * Users API Routes
*/ */
async function routes (app, options) { async function routes(app, options) {
app.get('/', { app.get(
'/',
{
schema: { schema: {
summary: 'List all users', summary: 'List all users',
tags: ['Users'] tags: ['Users']
} }
}, async (request, reply) => { },
async (request, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
app.get('/:userId', { 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
}
}
}
)
app.get(
'/:userId',
{
schema: { schema: {
summary: 'Get user info', summary: 'Get user info',
tags: ['Users'] tags: ['Users']
} }
}, async (request, reply) => { },
async (request, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
app.post('/', { app.post(
'/',
{
schema: { schema: {
summary: 'Create a new user', summary: 'Create a new user',
tags: ['Users'] tags: ['Users']
} }
}, async (request, reply) => { },
async (request, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
app.put('/:userId', { app.put(
'/:userId',
{
schema: { schema: {
summary: 'Update a user', summary: 'Update a user',
tags: ['Users'] tags: ['Users']
} }
}, async (request, reply) => { },
async (request, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
app.delete('/:userId', { app.delete(
'/:userId',
{
schema: { schema: {
summary: 'Delete a user', summary: 'Delete a user',
tags: ['Users'] tags: ['Users']
} }
}, async (request, reply) => { },
async (request, reply) => {
return { hello: 'world' } return { hello: 'world' }
}) }
)
} }
export default routes export default routes

@ -27,12 +27,12 @@ export default {
/** /**
* Initialize DB * Initialize DB
*/ */
async init (workerMode = false) { async init(workerMode = false) {
WIKI.logger.info('Checking DB configuration...') WIKI.logger.info('Checking DB configuration...')
// Fetch DB Config // Fetch DB Config
this.config = (process.env.DATABASE_URL) this.config = process.env.DATABASE_URL
? { ? {
connectionString: process.env.DATABASE_URL connectionString: process.env.DATABASE_URL
} }
@ -46,7 +46,11 @@ export default {
// Handle SSL Options // 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 let sslOptions = null
if (dbUseSSL && isPlainObject(this.config) && WIKI.config.db?.sslOptions?.auto === false) { if (dbUseSSL && isPlainObject(this.config) && WIKI.config.db?.sslOptions?.auto === false) {
sslOptions = WIKI.config.db.sslOptions sslOptions = WIKI.config.db.sslOptions
@ -82,7 +86,7 @@ export default {
} }
if (dbUseSSL && isPlainObject(this.config)) { if (dbUseSSL && isPlainObject(this.config)) {
this.config.ssl = (sslOptions === true) ? { rejectUnauthorized: true } : sslOptions this.config.ssl = sslOptions === true ? { rejectUnauthorized: true } : sslOptions
} }
// Initialize Postgres Pool // Initialize Postgres Pool
@ -90,11 +94,15 @@ export default {
this.pool = new Pool({ this.pool = new Pool({
application_name: 'Wiki.js', application_name: 'Wiki.js',
...this.config, ...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}` 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 // Connect
await this.connect(db) await this.connect(db)
@ -104,7 +112,9 @@ export default {
const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true }) const dbVersion = semver.coerce(resVersion.rows[0].server_version, { loose: true })
this.VERSION = dbVersion.version this.VERSION = dbVersion.version
if (dbVersion.major < 16) { 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) process.exit(1)
} }
WIKI.logger.info(`Using PostgreSQL v${dbVersion.version} [ OK ]`) WIKI.logger.info(`Using PostgreSQL v${dbVersion.version} [ OK ]`)
@ -125,7 +135,7 @@ export default {
/** /**
* Subscribe to database LISTEN / NOTIFY for multi-instances events * Subscribe to database LISTEN / NOTIFY for multi-instances events
*/ */
async subscribeToNotifications () { async subscribeToNotifications() {
let connSettings = this.knex.client.connectionSettings let connSettings = this.knex.client.connectionSettings
if (typeof connSettings === 'string') { if (typeof connSettings === 'string') {
const encodedName = encodeURIComponent(`Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`) const encodedName = encodeURIComponent(`Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`)
@ -138,15 +148,15 @@ export default {
connSettings.application_name = `Wiki.js - ${WIKI.INSTANCE_ID}:PSUB` connSettings.application_name = `Wiki.js - ${WIKI.INSTANCE_ID}:PSUB`
} }
this.listener = new PGPubSub(connSettings, { this.listener = new PGPubSub(connSettings, {
log (ev) { log(ev) {
WIKI.logger.debug(ev) WIKI.logger.debug(ev)
} }
}) })
// -> Outbound events handling // -> Outbound events handling
this.listener.addChannel('wiki', payload => { this.listener.addChannel('wiki', (payload) => {
if (('event' in payload) && payload.source !== WIKI.INSTANCE_ID) { if ('event' in payload && payload.source !== WIKI.INSTANCE_ID) {
WIKI.logger.info(`Received event ${payload.event} from instance ${payload.source}: [ OK ]`) WIKI.logger.info(`Received event ${payload.event} from instance ${payload.source}: [ OK ]`)
WIKI.events.inbound.emit(payload.event, payload.value) WIKI.events.inbound.emit(payload.event, payload.value)
} }
@ -164,7 +174,7 @@ export default {
/** /**
* Unsubscribe from database LISTEN / NOTIFY * Unsubscribe from database LISTEN / NOTIFY
*/ */
async unsubscribeToNotifications () { async unsubscribeToNotifications() {
if (this.listener) { if (this.listener) {
WIKI.events.outbound.offAny(this.notifyViaDB) WIKI.events.outbound.offAny(this.notifyViaDB)
WIKI.events.inbound.removeAllListeners() WIKI.events.inbound.removeAllListeners()
@ -177,7 +187,7 @@ export default {
* @param {string} event Event fired * @param {string} event Event fired
* @param {object} value Payload of the event * @param {object} value Payload of the event
*/ */
notifyViaDB (event, value) { notifyViaDB(event, value) {
WIKI.db.listener.publish('wiki', { WIKI.db.listener.publish('wiki', {
source: WIKI.INSTANCE_ID, source: WIKI.INSTANCE_ID,
event, event,
@ -187,7 +197,7 @@ export default {
/** /**
* Attempt initial connection * Attempt initial connection
*/ */
async connect (db) { async connect(db) {
try { try {
WIKI.logger.info('Connecting to database...') WIKI.logger.info('Connecting to database...')
await db.execute('SELECT 1 + 1;') await db.execute('SELECT 1 + 1;')
@ -211,7 +221,7 @@ export default {
/** /**
* Migrate DB Schemas * Migrate DB Schemas
*/ */
async syncSchemas (db) { async syncSchemas(db) {
WIKI.logger.info('Ensuring DB schema exists...') WIKI.logger.info('Ensuring DB schema exists...')
await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`) await db.execute(`CREATE SCHEMA IF NOT EXISTS ${WIKI.config.db.schema}`)
WIKI.logger.info('Ensuring DB migrations have been applied...') WIKI.logger.info('Ensuring DB migrations have been applied...')

@ -1,8 +1,7 @@
import { defineRelations } from 'drizzle-orm' import { defineRelations } from 'drizzle-orm'
import * as schema from './schema.mjs' import * as schema from './schema.mjs'
export const relations = defineRelations(schema, export const relations = defineRelations(schema, (r) => ({
r => ({
users: { users: {
groups: r.many.groups({ groups: r.many.groups({
from: r.users.id.through(r.userGroups.userId), from: r.users.id.through(r.userGroups.userId),
@ -11,6 +10,11 @@ export const relations = defineRelations(schema,
}, },
groups: { groups: {
members: r.many.users() members: r.many.users()
} },
userKeys: {
user: r.one.users({
from: r.userKeys.userId,
to: r.users.id
}) })
) }
}))

@ -49,6 +49,10 @@ const WIKI = {
ROOTPATH: process.cwd(), ROOTPATH: process.cwd(),
INSTANCE_ID: nanoid(10), INSTANCE_ID: nanoid(10),
SERVERPATH: path.join(process.cwd(), 'backend'), SERVERPATH: path.join(process.cwd(), 'backend'),
auth: {
groups: {},
strategies: {}
},
configSvc, configSvc,
sites: {}, sites: {},
sitesMappings: {}, sitesMappings: {},
@ -88,7 +92,7 @@ WIKI.logger.info(`Running node.js ${process.version} [ OK ]`)
// Pre-Boot Sequence // Pre-Boot Sequence
// ---------------------------------------- // ----------------------------------------
async function preBoot () { async function preBoot() {
WIKI.dbManager = (await import('./core/db.mjs')).default WIKI.dbManager = (await import('./core/db.mjs')).default
WIKI.db = await dbManager.init() WIKI.db = await dbManager.init()
WIKI.models = (await import('./models/index.mjs')).default WIKI.models = (await import('./models/index.mjs')).default
@ -119,11 +123,12 @@ async function preBoot () {
// Post-Boot Sequence // Post-Boot Sequence
// ---------------------------------------- // ----------------------------------------
async function postBoot () { async function postBoot() {
await WIKI.models.locales.refreshFromDisk() await WIKI.models.locales.refreshFromDisk()
await WIKI.models.authentication.refreshStrategiesFromDisk() await WIKI.models.authentication.refreshStrategiesFromDisk()
await WIKI.models.authentication.activateStrategies()
await WIKI.models.locales.reloadCache() await WIKI.models.locales.reloadCache()
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()
} }
@ -132,7 +137,7 @@ async function postBoot () {
// Init HTTP Server // Init HTTP Server
// ---------------------------------------- // ----------------------------------------
async function initHTTPServer () { async function initHTTPServer() {
// ---------------------------------------- // ----------------------------------------
// Load core modules // Load core modules
// ---------------------------------------- // ----------------------------------------
@ -147,9 +152,7 @@ async function initHTTPServer () {
const app = fastify({ const app = fastify({
ajv: { ajv: {
plugins: [ plugins: [[ajvFormats, {}]]
[ajvFormats, {}]
]
}, },
bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb
logger: { logger: {
@ -232,7 +235,7 @@ async function initHTTPServer () {
}) })
// ---------------------------------------- // ----------------------------------------
// Passport Authentication // Sessions
// ---------------------------------------- // ----------------------------------------
app.register(fastifyCookie, { app.register(fastifyCookie, {
@ -244,18 +247,31 @@ async function initHTTPServer () {
cookieName: 'wikiSession', cookieName: 'wikiSession',
cookie: { cookie: {
httpOnly: true, httpOnly: true,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
secure: 'auto' secure: 'auto'
}, },
saveUninitialized: false, saveUninitialized: false,
store: { 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: [ security: [{ apiKeyAuth: [] }, { bearerAuth: [] }]
{ apiKeyAuth: [] },
{ bearerAuth: [] }
]
} }
}) })
app.register(fastifySwaggerUi, { app.register(fastifySwaggerUi, {
@ -309,18 +322,18 @@ async function initHTTPServer () {
app.addHook('preHandler', (req, reply, done) => { app.addHook('preHandler', (req, reply, done) => {
if (req.routeOptions.config?.permissions?.length > 0) { if (req.routeOptions.config?.permissions?.length > 0) {
// Unauthenticated / No Permissions // Unauthenticated / No Permissions
if (!req.user?.isAuthenticated || !(req.user.permissions?.length > 0)) { if (!req.session?.authenticated || !(req.session?.permissions?.length > 0)) {
return reply.unauthorized() return reply.unauthorized()
} }
// Is Root Admin? // Is Root Admin?
if (!req.user.permissions.includes('manage:system')) { if (!req.session.permissions.includes('manage:system')) {
// Check for at least 1 permission // 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 // Check for all permissions
if (Array.isArray(perms)) { 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 { } else {
return req.user.permissions?.some(p => p === perms) return req.session.permissions?.some((p) => p === perms)
} }
}) })
// Forbidden // Forbidden

@ -9,37 +9,88 @@ import { authentication as authenticationTable } from '../db/schema.mjs'
* Authentication model * Authentication model
*/ */
class Authentication { class Authentication {
async getStrategy (module) { async getStrategy(module) {
return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module)) return WIKI.db.select().from(authenticationTable).where(eq(authenticationTable.module, module))
} }
async getStrategies ({ enabledOnly = false } = {}) { async getStrategies({ enabledOnly = false } = {}) {
return WIKI.db.select().from(authenticationTable).where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined) return WIKI.db
.select()
.from(authenticationTable)
.where(enabledOnly ? eq(authenticationTable.isEnabled, true) : undefined)
} }
async refreshStrategiesFromDisk () { async refreshStrategiesFromDisk() {
try { try {
// -> Fetch definitions from disk // -> 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 = [] WIKI.data.authentication = []
for (const dir of authenticationDirs) { 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) const defParsed = yaml.load(def)
if (!defParsed.isAvailable) { continue } if (!defParsed.isAvailable) {
continue
}
defParsed.key = dir defParsed.key = dir
defParsed.props = parseModuleProps(defParsed.props) defParsed.props = parseModuleProps(defParsed.props)
WIKI.data.authentication.push(defParsed) WIKI.data.authentication.push(defParsed)
WIKI.logger.debug(`Loaded authentication module definition ${dir} [ OK ]`) 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) { } 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) 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({ await WIKI.db.insert(authenticationTable).values({
id: ids.authModuleId, id: ids.authModuleId,
module: 'local', module: 'local',

@ -1,6 +1,7 @@
import { authentication } from './authentication.mjs' import { authentication } from './authentication.mjs'
import { groups } from './groups.mjs' import { groups } from './groups.mjs'
import { locales } from './locales.mjs' import { locales } from './locales.mjs'
import { sessions } from './sessions.mjs'
import { settings } from './settings.mjs' import { settings } from './settings.mjs'
import { sites } from './sites.mjs' import { sites } from './sites.mjs'
import { users } from './users.mjs' import { users } from './users.mjs'
@ -9,6 +10,7 @@ export default {
authentication, authentication,
groups, groups,
locales, locales,
sessions,
settings, settings,
sites, sites,
users users

@ -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<Array> 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<Object> 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<void>
*/
async destroy(id) {
return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.id, id))
}
/**
* Delete all sessions from all users
*
* @returns Promise<void>
*/
async clearAllSessions() {
return WIKI.db.delete(sessionsTable)
}
/**
* Delete all sessions from a single user
*
* @param {String} userId User ID
* @returns Promise<void>
*/
async clearSessionsFromUser(userId) {
return WIKI.db.delete(sessionsTable).where(eq(sessionsTable.userId, userId))
}
}
export const sessions = new Sessions()

@ -7,14 +7,14 @@ import { eq } from 'drizzle-orm'
* Sites model * Sites model
*/ */
class Sites { class Sites {
async getSiteById ({ id, forceReload = false }) { async getSiteById({ id, forceReload = false }) {
if (forceReload) { if (forceReload) {
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()
} }
return WIKI.sites[id] return WIKI.sites[id]
} }
async getSiteByHostname ({ hostname, forceReload = false }) { async getSiteByHostname({ hostname, forceReload = false }) {
if (forceReload) { if (forceReload) {
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()
} }
@ -25,10 +25,14 @@ class Sites {
return null return null
} }
async reloadCache () { async getAllSites() {
return WIKI.db.select().from(sitesTable).orderBy(sitesTable.hostname)
}
async reloadCache() {
WIKI.logger.info('Reloading site configurations...') WIKI.logger.info('Reloading site configurations...')
const sites = await WIKI.db.select().from(sitesTable).orderBy(sitesTable.id) 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 = {} WIKI.sitesMappings = {}
for (const site of sites) { for (const site of sites) {
WIKI.sitesMappings[site.hostname] = site.id WIKI.sitesMappings[site.hostname] = site.id
@ -36,11 +40,14 @@ class Sites {
WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`) WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`)
} }
async createSite (hostname, config) { async createSite(hostname, config) {
const result = await WIKI.db.insert(sitesTable).values({ const result = await WIKI.db
.insert(sitesTable)
.values({
hostname, hostname,
isEnabled: true, isEnabled: true,
config: toMerged({ config: toMerged(
{
title: 'My Wiki Site', title: 'My Wiki Site',
description: '', description: '',
company: '', company: '',
@ -133,8 +140,11 @@ class Sites {
conflictBehavior: 'overwrite', conflictBehavior: 'overwrite',
normalizeFilename: true normalizeFilename: true
} }
}, config) },
}).returning({ id: sitesTable.id }) config
)
})
.returning({ id: sitesTable.id })
const newSite = result[0] const newSite = result[0]
@ -168,21 +178,21 @@ class Sites {
return newSite return newSite
} }
async updateSite (id, patch) { async updateSite(id, patch) {
return WIKI.db.sites.query().findById(id).patch(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) // await WIKI.db.storage.query().delete().where('siteId', id)
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id)) const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
return Boolean(deletedResult.rowCount > 0) return Boolean(deletedResult.rowCount > 0)
} }
async countSites () { async countSites() {
return WIKI.db.$count(sitesTable) return WIKI.db.$count(sitesTable)
} }
async init (ids) { async init(ids) {
WIKI.logger.info('Inserting default site...') WIKI.logger.info('Inserting default site...')
await WIKI.db.insert(sitesTable).values({ await WIKI.db.insert(sitesTable).values({

@ -1,13 +1,19 @@
/* global WIKI */
import bcrypt from 'bcryptjs' 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 * Users model
*/ */
class Users { 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...') WIKI.logger.info('Inserting default users...')
await WIKI.db.insert(usersTable).values([ 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, userId: ids.userAdminId,
groupId: ids.groupAdminId groupId: ids.groupAdminId
@ -72,46 +78,245 @@ class Users {
]) ])
} }
async login ({ siteId, strategyId, username, password, ip }) { async login({ siteId, strategyId, username, password, ip }, req) {
if (strategyId in WIKI.auth.strategies) { if (strategyId in WIKI.auth.strategies) {
const selStrategy = WIKI.auth.strategies[strategyId] const str = WIKI.auth.strategies[strategyId]
if (!selStrategy.isEnabled) { const strInfo = WIKI.data.authentication.find((a) => a.key === str.module)
throw new Error('Inactive Strategy ID') 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)
// Perform post-login checks
return this.afterLoginChecks(
user,
strategyId,
context,
{
skipTFA: !strInfo.useForm,
skipChangePwd: !strInfo.useForm
},
req
)
} else {
throw new Error('Invalid Strategy ID')
}
}
// Inject form user/pass async afterLoginChecks(
if (strInfo.useForm) { user,
set(context.req, 'body.email', username) strategyId,
set(context.req, 'body.password', password) context,
set(context.req.params, 'strategy', strategyId) { skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false },
req
) {
const str = WIKI.auth.strategies[strategyId]
if (!str) {
throw new Error('ERR_INVALID_STRATEGY')
} }
// Authenticate // Get user groups
return new Promise((resolve, reject) => { user.groups = await WIKI.db.query.users
WIKI.auth.passport.authenticate(selStrategy.id, { .findFirst({
session: !strInfo.useForm, columns: {},
scope: strInfo.scopes ? strInfo.scopes : null where: {
}, async (err, user, info) => { id: user.id
if (err) { return reject(err) } },
if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) } with: {
groups: {
columns: {
id: true,
permissions: true,
redirectOnLogin: true
}
}
}
})
.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 { try {
const resp = await WIKI.db.users.afterLoginChecks(user, selStrategy.id, context, { const tfaToken = await WIKI.db.userKeys.generateToken({
siteId, kind: 'tfa',
skipTFA: !strInfo.useForm, userId: user.id,
skipChangePwd: !strInfo.useForm meta: {
strategyId
}
}) })
resolve(resp) return {
} catch (err) { nextAction: 'provideTfa',
reject(err) 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
} }
})(context.req, context.res, () => {})
}) })
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 { } 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))
} }
} }

@ -4,25 +4,19 @@ import bcrypt from 'bcryptjs'
// ------------------------------------ // ------------------------------------
// Local Account // Local Account
// ------------------------------------ // ------------------------------------
export default class LocalAuthentication {
constructor(strategyId, conf) {
this.strategyId = strategyId
this.conf = conf
}
import { Strategy } from 'passport-local' async authenticate({ username, password }) {
const user = await WIKI.models.users.getByEmail(username.toLowerCase())
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) { if (user) {
const authStrategyData = user.auth[strategyId] const authStrategyData = user.auth[this.strategyId]
if (!authStrategyData) { if (!authStrategyData) {
throw new Error('ERR_INVALID_STRATEGY') throw new Error('ERR_INVALID_STRATEGY')
} else if (await bcrypt.compare(uPassword, authStrategyData.password) !== true) { } else if ((await bcrypt.compare(password, authStrategyData.password)) !== true) {
throw new Error('ERR_LOGIN_FAILED') throw new Error('ERR_LOGIN_FAILED')
} else if (!user.isActive) { } else if (!user.isActive) {
throw new Error('ERR_INACTIVE_USER') throw new Error('ERR_INACTIVE_USER')
@ -31,15 +25,10 @@ export default {
} else if (!user.isVerified) { } else if (!user.isVerified) {
throw new Error('ERR_USER_NOT_VERIFIED') throw new Error('ERR_USER_NOT_VERIFIED')
} else { } else {
done(null, user) return user
} }
} else { } else {
throw new Error('ERR_LOGIN_FAILED') throw new Error('ERR_LOGIN_FAILED')
} }
} catch (err) {
done(err, null)
}
})
)
} }
} }

@ -29,6 +29,7 @@
"es-toolkit": "1.45.1", "es-toolkit": "1.45.1",
"fastify": "5.7.1", "fastify": "5.7.1",
"fastify-favicon": "5.0.0", "fastify-favicon": "5.0.0",
"filesize": "11.0.13",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"luxon": "3.7.2", "luxon": "3.7.2",
"mime": "4.1.0", "mime": "4.1.0",
@ -3124,6 +3125,15 @@
"reusify": "^1.0.4" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",

@ -58,6 +58,7 @@
"es-toolkit": "1.45.1", "es-toolkit": "1.45.1",
"fastify": "5.7.1", "fastify": "5.7.1",
"fastify-favicon": "5.0.0", "fastify-favicon": "5.0.0",
"filesize": "11.0.13",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"luxon": "3.7.2", "luxon": "3.7.2",
"mime": "4.1.0", "mime": "4.1.0",

@ -105,5 +105,7 @@ scheduler:
# Settings when running in dev mode only # Settings when running in dev mode only
dev: dev:
dropSchema: false
logQueries: false
port: 3001 port: 3001
hmrClientPort: 3001 hmrClientPort: 3001

@ -1,9 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": true,
"endOfLine": "lf",
"insertFinalNewline": true
}

@ -124,9 +124,9 @@ router.beforeEach(async (to, from) => {
commonStore.routerLoading = true commonStore.routerLoading = true
// -> Init Auth Token // -> Init Auth Token
if (userStore.token && !userStore.authenticated) { // if (userStore.token && !userStore.authenticated) {
userStore.loadToken() // userStore.loadToken()
} // }
// -> System Flags // -> System Flags
if (!flagsStore.loaded) { if (!flagsStore.loaded) {
@ -147,8 +147,8 @@ router.beforeEach(async (to, from) => {
applyLocale(commonStore.desiredLocale) applyLocale(commonStore.desiredLocale)
// -> User Profile // -> User Profile
if (userStore.authenticated && !userStore.profileLoaded) { if (!userStore.profileLoaded) {
console.info(`Refreshing user ${userStore.id} profile...`) console.info(`Refreshing user profile...`)
await userStore.refreshProfile() await userStore.refreshProfile()
} }

@ -2,7 +2,7 @@ import ky from 'ky'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
export function initializeApi (store) { export function initializeApi(store) {
const userStore = useUserStore(store) const userStore = useUserStore(store)
let refreshPromise = null let refreshPromise = null
@ -10,7 +10,7 @@ export function initializeApi (store) {
const client = ky.create({ const client = ky.create({
prefixUrl: '/_api', prefixUrl: '/_api',
credentials: 'omit', credentials: 'same-origin',
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
async (request) => { async (request) => {
@ -24,7 +24,7 @@ export function initializeApi (store) {
if (!userStore.isTokenValid({ minutes: 1 })) { if (!userStore.isTokenValid({ minutes: 1 })) {
if (!fetching) { if (!fetching) {
refreshPromise = new Promise((resolve, reject) => { refreshPromise = new Promise((resolve, reject) => {
(async () => { ;(async () => {
fetching = true fetching = true
try { try {
await userStore.refreshToken() await userStore.refreshToken()

@ -556,7 +556,6 @@ async function handleLoginResponse (resp) {
$q.loading.show({ $q.loading.show({
message: t('auth.loginSuccess') message: t('auth.loginSuccess')
}) })
Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
setTimeout(() => { setTimeout(() => {
const loginRedirect = Cookies.get('loginRedirect') const loginRedirect = Cookies.get('loginRedirect')
if (loginRedirect === '/' && resp.redirect) { if (loginRedirect === '/' && resp.redirect) {
@ -595,20 +594,22 @@ async function login () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('auth.errors.login')) 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: { json: {
strategyId: state.selectedStrategyId, strategyId: state.selectedStrategyId,
username: state.username, username: state.username,
password: state.password password: state.password
} },
throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json() }).json()
if (resp.operation?.succeeded) { if (resp.ok) {
state.password = '' state.password = ''
handleLoginResponse(resp) handleLoginResponse(resp)
} else { } else {
throw new Error(resp.operation?.message || t('auth.errors.loginError')) throw new Error(resp.message || t('auth.errors.loginError'))
} }
} catch (err) { } catch (err) {
console.warn(err)
$q.loading.hide() $q.loading.hide()
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
@ -778,48 +779,23 @@ async function changePwd () {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('auth.errors.register')) throw new Error(t('auth.errors.register'))
} }
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put(`sites/${siteStore.id}/auth/changePassword`, {
mutation: ` json: {
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,
strategyId: state.selectedStrategyId, strategyId: state.selectedStrategyId,
siteId: siteStore.id continuationToken: state.continuationToken,
} newPassword: state.newPassword
}) },
if (resp.data?.changePassword?.operation?.succeeded) { throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400
}).json()
if (resp.ok) {
state.password = '' state.password = ''
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('auth.changePwd.success') message: t('auth.changePwd.success')
}) })
await handleLoginResponse(resp.data.changePassword) await handleLoginResponse(resp)
} else { } else {
throw new Error(resp.data?.changePassword?.operation?.message || t('auth.errors.loginError')) throw new Error(resp.message || t('auth.errors.loginError'))
} }
} catch (err) { } catch (err) {
$q.notify({ $q.notify({

@ -260,7 +260,7 @@ const platformLogo = computed(() => {
case 'darwin': case 'darwin':
return 'apple-logo' return 'apple-logo'
case 'linux': case 'linux':
if (this.info.operatingSystem.indexOf('Ubuntu') >= 0) { if (state.info.operatingSystem.indexOf('Ubuntu') >= 0) {
return 'ubuntu' return 'ubuntu'
} else { } else {
return 'linux' return 'linux'
@ -292,30 +292,7 @@ const clientViewport = computed(() => {
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ state.info = await API_CLIENT.get('system/info').json()
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)
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }

@ -3,8 +3,6 @@ import { defineStore } from 'pinia'
import { clone, cloneDeep, sortBy } from 'lodash-es' import { clone, cloneDeep, sortBy } from 'lodash-es'
import semverGte from 'semver/functions/gte' import semverGte from 'semver/functions/gte'
/* global APOLLO_CLIENT */
export const useAdminStore = defineStore('admin', { export const useAdminStore = defineStore('admin', {
state: () => ({ state: () => ({
currentSiteId: null, currentSiteId: null,
@ -23,80 +21,41 @@ export const useAdminStore = defineStore('admin', {
overlay: null, overlay: null,
overlayOpts: {}, overlayOpts: {},
sites: [], sites: [],
locales: [ locales: [{ code: 'en', name: 'English' }]
{ code: 'en', name: 'English' }
]
}), }),
getters: { getters: {
isVersionLatest: (state) => { 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 false
} }
return semverGte(state.info.currentVersion, state.info.latestVersion) return semverGte(state.info.currentVersion, state.info.latestVersion)
} }
}, },
actions: { actions: {
async fetchLocales () { async fetchLocales() {
const resp = await APOLLO_CLIENT.query({ const resp = await API_CLIENT.get('locales').json()
query: ` this.locales = sortBy(cloneDeep(resp ?? []), ['nativeName', 'name'])
query getAdminLocales {
locales {
code
language
name
nativeName
}
}
`
})
this.locales = sortBy(cloneDeep(resp?.data?.locales ?? []), ['nativeName', 'name'])
}, },
async fetchInfo () { async fetchInfo() {
const resp = await APOLLO_CLIENT.query({ const resp = await API_CLIENT.get('system/info').json()
query: ` this.info.groupsTotal = clone(resp?.groupsTotal ?? 0)
query getAdminInfo { this.info.tagsTotal = clone(resp?.tagsTotal ?? 0)
apiState this.info.usersTotal = clone(resp?.usersTotal ?? 0)
metricsState this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0)
systemInfo { this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a')
groupsTotal this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a')
tagsTotal this.info.isApiEnabled = clone(resp?.apiState ?? false)
usersTotal this.info.isMetricsEnabled = clone(resp?.metricsState ?? false)
loginsPastDay this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false)
currentVersion this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false)
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 fetchSites () { async fetchSites() {
const resp = await APOLLO_CLIENT.query({ this.sites = (await API_CLIENT.get('sites').json()) ?? []
query: `
query getSites {
sites {
id
hostname
isEnabled
title
}
}
`,
fetchPolicy: 'network-only'
})
this.sites = cloneDeep(resp?.data?.sites ?? [])
if (!this.currentSiteId) { if (!this.currentSiteId) {
this.currentSiteId = this.sites[0].id this.currentSiteId = this.sites[0].id
} }

@ -21,10 +21,7 @@ export const useUserStore = defineStore('user', {
cvd: 'none', cvd: 'none',
permissions: [], permissions: [],
pagePermissions: [], pagePermissions: [],
iat: 0,
exp: null,
authenticated: false, authenticated: false,
token: Cookies.get('jwt'),
profileLoaded: false profileLoaded: false
}), }),
getters: { getters: {
@ -40,162 +37,93 @@ export const useUserStore = defineStore('user', {
} }
}, },
actions: { actions: {
isTokenValid (offset) { async refreshProfile() {
return this.exp && this.exp > (offset ? DateTime.now().plus(offset) : DateTime.now())
},
loadToken () {
if (!this.token) { return }
try { try {
const jwtData = jwtDecode(this.token) const resp = await API_CLIENT.get('users/whoami', {
this.id = jwtData.id cache: 'no-store'
this.email = jwtData.email }).json()
this.iat = jwtData.iat if (!resp || !resp.authenticated) {
this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' }) this.setToGuest()
if (this.exp > DateTime.utc()) {
this.authenticated = true
} else { } else {
console.info('Token has expired and will be refreshed on next query.') this.$patch({
} name: resp.name || 'Unknown User',
} catch (err) { email: resp.email,
console.warn('Failed to parse JWT. Invalid or malformed.') hasAvatar: resp.hasAvatar ?? false,
} location: resp.location || '',
}, jobTitle: resp.jobTitle || '',
async refreshToken () { pronouns: resp.pronouns || '',
try { timezone: resp.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
const respRaw = await APOLLO_CLIENT.mutate({ dateFormat: resp.dateFormat || '',
context: { timeFormat: resp.timeFormat || '12h',
skipAuth: true appearance: resp.appearance || 'site',
}, cvd: resp.cvd || 'none',
mutation: ` permissions: resp.permissions || [],
mutation refreshToken ( authenticated: true,
$token: String! profileLoaded: true
) {
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.')
} }
Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
this.token = resp.jwt
this.loadToken()
return true
} catch (err) { } catch (err) {
console.warn(err) console.warn(err)
return false
} }
}, },
async refreshProfile () { async logout() {
if (!this.authenticated || !this.id) { const siteStore = useSiteStore()
return await API_CLIENT.get(`sites/${siteStore.id}/auth/logout`).json()
} this.setToGuest()
try { EVENT_BUS.emit('logout')
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)
}
}, },
logout () { setToGuest() {
Cookies.remove('jwt', { path: '/' })
this.$patch({ this.$patch({
id: '10000000-0000-4000-8000-000000000001', id: '10000000-0000-4000-8000-000000000001',
email: '', email: '',
name: '', name: '',
hasAvatar: false, hasAvatar: false,
localeCode: '',
timezone: '', timezone: '',
dateFormat: 'YYYY-MM-DD', dateFormat: 'YYYY-MM-DD',
timeFormat: '12h', timeFormat: '12h',
appearance: 'site', appearance: 'site',
cvd: 'none', cvd: 'none',
permissions: [], permissions: [],
iat: 0,
exp: null,
authenticated: false, authenticated: false,
token: '',
profileLoaded: false profileLoaded: false
}) })
EVENT_BUS.emit('logout')
}, },
getAccessibleColor (base, hexBase) { getAccessibleColor(base, hexBase) {
return getAccessibleColor(base, hexBase, this.cvd) return getAccessibleColor(base, hexBase, this.cvd)
}, },
can (permission) { can(permission) {
if (this.permissions.includes('manage:system') || this.permissions.includes(permission) || this.pagePermissions.includes(permission)) { if (
this.permissions.includes('manage:system') ||
this.permissions.includes(permission) ||
this.pagePermissions.includes(permission)
) {
return true return true
} }
return false return false
}, },
async fetchPagePermissions (path) { async fetchPagePermissions(path) {
if (path.startsWith('/_')) { if (path.startsWith('/_')) {
this.pagePermissions = [] this.pagePermissions = []
return return
} }
const siteStore = useSiteStore() const siteStore = useSiteStore()
try { try {
this.pagePermissions = await API_CLIENT.post(`sites/${siteStore.id}/pages/userPermissions`, { this.pagePermissions = await API_CLIENT.post(
`sites/${siteStore.id}/pages/userPermissions`,
{
json: { json: {
path path
} }
}).json() }
).json()
} catch (err) { } catch (err) {
console.warn(`Failed to fetch page permissions at path ${path}!`) console.warn(`Failed to fetch page permissions at path ${path}!`)
} }
}, },
formatDateTime (t, date) { formatDateTime(t, date) {
return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat(t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat })) return (typeof date === 'string' ? DateTime.fromISO(date) : date).toFormat(
t('common.datetime', { date: this.preferredDateFormat, time: this.preferredTimeFormat })
)
} }
} }
}) })

Loading…
Cancel
Save