diff --git a/.changeset/1719706755991.md b/.changeset/1719706755991.md new file mode 100644 index 00000000..8b319d1f --- /dev/null +++ b/.changeset/1719706755991.md @@ -0,0 +1,6 @@ +--- +'@pockethost/plugin-local-auth': minor +'pockethost': minor +--- + +Initial commit \ No newline at end of file diff --git a/packages/plugin-local-auth/LICENSE.md b/packages/plugin-local-auth/LICENSE.md new file mode 100644 index 00000000..6f385987 --- /dev/null +++ b/packages/plugin-local-auth/LICENSE.md @@ -0,0 +1,16 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/plugin-local-auth/package.json b/packages/plugin-local-auth/package.json new file mode 100644 index 00000000..55ed1137 --- /dev/null +++ b/packages/plugin-local-auth/package.json @@ -0,0 +1,43 @@ +{ + "name": "@pockethost/plugin-local-auth", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "http://github.com/pockethost/pockethost/packages/plugin-local-auth" + }, + "description": "A pockethost plugin.", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "type": "module", + "scripts": { + "check-types": "tsc --noEmit " + }, + "keywords": [ + "pockethost" + ], + "author": { + "name": "Ben Allfree", + "url": "https://github.com/benallfree" + }, + "license": "MIT", + "peerDependencies": { + "pockethost": "workspace:^1.5.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/inquirer": "^9.0.7", + "@types/lowdb": "^1.0.15", + "@types/node": "^20.8.10", + "typescript": "^5.4.5" + }, + "dependencies": { + "@s-libs/micro-dash": "^16.1.0", + "bcryptjs": "^2.4.3", + "commander": "^11.1.0", + "immer": "^10.1.1", + "inflection": "^3.0.0", + "inquirer": "^9.2.15", + "lowdb": "^7.0.1" + } +} diff --git a/packages/plugin-local-auth/readme.md b/packages/plugin-local-auth/readme.md new file mode 100644 index 00000000..c22cb6e9 --- /dev/null +++ b/packages/plugin-local-auth/readme.md @@ -0,0 +1,67 @@ +# plugin-local-auth + +A plugin for [pockethost](https://www.npmjs.com/package/pockethost). + +This plugin implements a local user auth database. + +## Quickstart + +**Install** + +```bash +npx pockethost plugin install @pockethost/plugin-local-auth +``` + +**Get help** + +```bash +npx pockethost local-auth --help +``` + +**Create user/admin** + +```bash +npx pockethost local-auth create foo@bar.com myPassword +npx pockethost local-auth create admin@bar.com myPassword --admin +``` + +**Delete user** + +```bash +npx pockethost local-auth delete foo@bar.com +``` + +**List users** + +```bash +npx pockethost local-auth ls +``` + +## Variables + +The following variables will be used if they are found in the shell environment. PocketHost will also load them from an `.env` file if found at load time. + +| Name | Default | Discussion | +| ------------------ | ------------------------------- | ------------------------------------------------------------- | +| PH_LOCAL_AUTH_HOME | `.pockethost/plugin-local-auth` | The home directory for any data storage needs of this plugin. | + +## Actions + +## Filters + +## Related Plugins + +`local-auth` pairs well with: + +- `@pockethost/plugin-ftp-server` +- `@pockethost/plugin-auto-admin` + +## Support + +PocketHost has a thriving [Discord community](https://discord.gg/nVTxCMEcGT). + +--- + +### Sponsored by https://pockethost.io. Instantly host your PocketBase projects. + +--- diff --git a/packages/plugin-local-auth/src/AuthCommand.ts b/packages/plugin-local-auth/src/AuthCommand.ts new file mode 100644 index 00000000..664fae60 --- /dev/null +++ b/packages/plugin-local-auth/src/AuthCommand.ts @@ -0,0 +1,85 @@ +import { forEach } from '@s-libs/micro-dash' +import { Command } from 'commander' +import { gracefulExit } from 'pockethost/core' +import { DbService } from './db' +import { error, info } from './log' + +export const AuthCommand = async () => { + const { + addUser, + getFirstUserByExactCriteria, + getAllUsersByExactCriteria, + authenticate, + deleteUserByExactCriteria, + } = await DbService() + + const cmd = new Command(`auth`) + .description(`Command root`) + .addCommand( + new Command(`add`) + .argument(``, `User name`) + .argument(``, `User password`) + .alias(`a`) + .option(`-a, --admin`, `Make the user an admin`) + .description(`Add a user`) + .action(async (username, password, options) => { + if (getFirstUserByExactCriteria({ username })) { + error(`User ${username} already exists`) + await gracefulExit() + } + await addUser(username, password, options.admin) + }), + ) + .addCommand( + new Command(`update`) + .argument(``, `User name`) + .argument(``, `User password`) + .option(`-a, --admin`, `Make the user an admin`) + .alias(`u`) + .alias(`up`) + .description(`Update a user`) + .action(async (username: string, password: string, options) => { + await deleteUserByExactCriteria({ username }) + await addUser(username, password, options.admin) + }), + ) + .addCommand( + new Command(`delete`) + .argument(``, `User ID`) + .alias(`d`) + .alias(`del`) + .alias(`rm`) + .description(`Delete a user`) + .action(async (username: string, options) => { + await deleteUserByExactCriteria({ username }) + }), + ) + .addCommand( + new Command(`list`) + .alias(`ls`) + .description(`List users`) + .action(async (options) => { + info(`Listing users`) + const users = getAllUsersByExactCriteria({}) + forEach(users, (user) => { + info(`${user.username}`, user.isAdmin ? `(admin)` : ``) + }) + }), + ) + .addCommand( + new Command(`login`) + .argument(``, `User name`) + .argument(``, `User password`) + .alias(`a`) + .description(`Test the user login`) + .action(async (username, password, options) => { + const isAuthenticated = authenticate(username, password) + if (isAuthenticated) { + info(`User ${username} authenticated`) + } else { + error(`User ${username} not authenticated`) + } + }), + ) + return cmd +} diff --git a/packages/plugin-local-auth/src/constants.ts b/packages/plugin-local-auth/src/constants.ts new file mode 100644 index 00000000..739ad26b --- /dev/null +++ b/packages/plugin-local-auth/src/constants.ts @@ -0,0 +1,21 @@ +import { underscore } from 'inflection' +import { dirname, join } from 'path' +import { PH_HOME, Settings, mkPath } from 'pockethost/core' +import { fileURLToPath } from 'url' + +export const PLUGIN_NAME = `plugin-local-auth` + +export const HOME_DIR = + process.env.PH_LOCAL_AUTH_HOME || join(PH_HOME(), PLUGIN_NAME) +export const PLUGIN_NAME_CONSTANT_CASE = underscore(PLUGIN_NAME, true) + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export const PLUGIN_DATA = (...paths: string[]) => join(HOME_DIR, ...paths) + +export const PROJECT_DIR = (...paths: string[]) => + join(__dirname, '..', ...paths) + +export const settings = Settings({ + PH_LOCAL_AUTH_HOME: mkPath(HOME_DIR, { create: true }), +}) diff --git a/packages/plugin-local-auth/src/db.ts b/packages/plugin-local-auth/src/db.ts new file mode 100644 index 00000000..aa130c37 --- /dev/null +++ b/packages/plugin-local-auth/src/db.ts @@ -0,0 +1,98 @@ +import bcrypt from 'bcryptjs' +import { JSONFileSyncPreset } from 'lowdb/node' +import { mkSingleton } from 'pockethost' +import { UserId } from 'pockethost/src/common/schema/BaseFields' +import { PLUGIN_DATA } from './constants' +import { info } from './log' + +export type UserFields = { + id: UserId + username: string + passwordHash: string + isAdmin: boolean +} + +export const DbService = mkSingleton(async () => { + const db = JSONFileSyncPreset<{ + defaultUid: UserId + users: { [_: UserId]: UserFields } + }>(PLUGIN_DATA('users.json'), { users: {}, defaultUid: '' }) + + const getUserByExactCriteria = (criteria: Partial) => { + return Object.values(db.data.users).find((user) => + Object.entries(criteria).every( + ([k, v]) => user[k as keyof UserFields] === v, + ), + ) + } + + const deleteUserByExactCriteria = (criteria: Partial) => { + const user = getUserByExactCriteria(criteria) + if (user) { + db.update((data) => { + delete data.users[user.id] + }) + info(`Deleted user ${user.username}`) + } + } + + const addUser = (username: string, password: string, isAdmin = false) => { + if (getUserByExactCriteria({ username })) { + throw new Error(`User ${username} already exists`) + } + const id = username + const passwordHash = bcrypt.hashSync(password, 10) + db.update((data) => { + data.users = { + ...data.users, + [id]: { id, username, passwordHash, isAdmin }, + } + }) + info(`Added user ${username}/${password}`) + return getFirstUserByExactCriteria({ id })! + } + + const getAllUsersByExactCriteria = (criteria: Partial) => { + return Object.values(db.data.users).filter((user) => + Object.entries(criteria).every( + ([k, v]) => user[k as keyof UserFields] === v, + ), + ) + } + + const getFirstUserByExactCriteria = (criteria: Partial) => { + return getAllUsersByExactCriteria(criteria)[0] + } + + const authenticate = ( + username: string, + password: string, + ): UserFields | undefined => { + const user = getFirstUserByExactCriteria({ username }) + if (user && bcrypt.compareSync(password, user.passwordHash)) { + return user + } + } + + const getDefaultUser = (): UserFields | undefined => { + return getFirstUserByExactCriteria({ id: db.data.defaultUid }) + } + + const setDefaultUser = (user: UserFields) => { + db.update((data) => { + data.defaultUid = user.id + }) + info(`Set default user to ${user.username}`) + } + + return { + addUser, + getUserByExactCriteria, + deleteUserByExactCriteria, + getAllUsersByExactCriteria, + getFirstUserByExactCriteria, + authenticate, + getDefaultUser, + setDefaultUser, + } +}) diff --git a/packages/plugin-local-auth/src/index.ts b/packages/plugin-local-auth/src/index.ts new file mode 100644 index 00000000..9e1018b3 --- /dev/null +++ b/packages/plugin-local-auth/src/index.ts @@ -0,0 +1,3 @@ +import { plugin } from './plugin' + +export default plugin diff --git a/packages/plugin-local-auth/src/instance-app/hooks/local-auth-example.pb.js b/packages/plugin-local-auth/src/instance-app/hooks/local-auth-example.pb.js new file mode 100644 index 00000000..fa950eec --- /dev/null +++ b/packages/plugin-local-auth/src/instance-app/hooks/local-auth-example.pb.js @@ -0,0 +1,15 @@ +/// + +$app.onBeforeServe().add((e) => { + const dao = $app.dao() + const { mkLog } = /** @type {Lib} */ (require(`${__hooks}/_ph_lib.js`)) + + const log = mkLog(`plugin-local-auth`) + + if (!$os.getenv(`PH_PLUGIN_LOCAL_AUTH_ENABLED`)) { + log(`Starting `) + } else { + log(`Not enabled. Skipping...`) + return + } +}) diff --git a/packages/plugin-local-auth/src/log.ts b/packages/plugin-local-auth/src/log.ts new file mode 100644 index 00000000..0865d2e8 --- /dev/null +++ b/packages/plugin-local-auth/src/log.ts @@ -0,0 +1,5 @@ +import { LoggerService } from 'pockethost' +import { PLUGIN_NAME } from './constants' + +const logger = LoggerService().create(PLUGIN_NAME) +export const { dbg, info, error } = logger diff --git a/packages/plugin-local-auth/src/plugin.ts b/packages/plugin-local-auth/src/plugin.ts new file mode 100644 index 00000000..6b3d592f --- /dev/null +++ b/packages/plugin-local-auth/src/plugin.ts @@ -0,0 +1,89 @@ +import { produce } from 'immer' +import inquirer from 'inquirer' +import { + PocketHostPlugin, + onAfterPluginsLoadedAction, + onAuthenticateUserFilter, + onCliCommandsFilter, + onInstanceConfigFilter, + onNewInstanceRecordFilter, + onSettingsFilter, +} from 'pockethost' +import { UserId } from 'pockethost/src/common/schema/BaseFields' +import { AuthCommand } from './AuthCommand' +import { + PLUGIN_NAME, + PLUGIN_NAME_CONSTANT_CASE, + PROJECT_DIR, + settings, +} from './constants' +import { DbService } from './db' +import { dbg, info } from './log' + +export type WithUid = { uid: UserId } + +export const plugin: PocketHostPlugin = async ({}) => { + dbg(`initializing ${PLUGIN_NAME}`) + + const { getDefaultUser, addUser, setDefaultUser, authenticate } = + await DbService({}) + + onInstanceConfigFilter(async (config) => { + return produce(config, (draft) => { + draft.env[`PH_${PLUGIN_NAME_CONSTANT_CASE}_ENABLED`] = true.toString() + draft.binds.hooks.push({ + src: PROJECT_DIR(`src/instance-app/hooks/**/*`), + base: PROJECT_DIR(`src/instance-app/hooks`), + }) + }) + }) + + onCliCommandsFilter(async (commands) => { + return [...commands, await AuthCommand()] + }) + + onSettingsFilter(async (allSettings) => ({ ...allSettings, ...settings })) + + onNewInstanceRecordFilter>(async (instance) => { + const uid = instance.uid || getDefaultUser()?.id + + return { ...instance, uid } + }) + + onAuthenticateUserFilter(async (userId, context) => { + const { username, password } = context + return userId || authenticate(username, password)?.id + }) + + onAfterPluginsLoadedAction(async () => { + if (getDefaultUser()) return + info(`Local auth needs a default admin user.`) + const answers = await inquirer.prompt<{ + username: string + password: string + }>([ + { + type: 'input', + name: 'username', + message: 'Username', + validate: (input) => { + if (!input) return 'Username cannot be empty' + return true + }, + }, + { + type: 'password', + name: 'password', + message: 'Password', + validate: (input) => { + if (!input) return 'Password cannot be empty' + return true + }, + }, + ]) + const { username, password } = answers + const user = addUser(username, password, true) + setDefaultUser(user) + info(`User ${user.username} created as admin.`) + }) +} diff --git a/packages/plugin-local-auth/tsconfig.json b/packages/plugin-local-auth/tsconfig.json new file mode 100644 index 00000000..d0035bbf --- /dev/null +++ b/packages/plugin-local-auth/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ES2021.String"], + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "noUncheckedIndexedAccess": true, + "strictNullChecks": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/pockethost/src/common/plugin/filter.ts b/packages/pockethost/src/common/plugin/filter.ts index 196e30ba..97bee0e4 100644 --- a/packages/pockethost/src/common/plugin/filter.ts +++ b/packages/pockethost/src/common/plugin/filter.ts @@ -8,6 +8,7 @@ export enum CoreFilters { CliCommands = 'core_cli_commands', ServeSlugs = 'core_serve_slugs', SpawnConfig = 'core_spawn_config', + AuthenticateUser = 'core_authenticate_user', GetInstanceByRequestInfo = 'core_get_instance_by_request_info', GetInstanceById = 'core_get_instance_by_id', InstanceConfig = 'core_instance_config',