refactor: auth + sessions

scarlett
NGPixel 5 days 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,
"singleQuote": true,
"trailingComma": "none",

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

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

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

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

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

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

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

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

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

@ -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',

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

@ -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
*/
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({

@ -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()

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

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

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

@ -105,5 +105,7 @@ scheduler:
# Settings when running in dev mode only
dev:
dropSchema: false
logQueries: false
port: 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
// -> 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()
}

@ -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()

@ -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({

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

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

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

Loading…
Cancel
Save