mirror of https://github.com/wg-easy/wg-easy
Browse Source
* add: database abstraction * update: get lang from database * udpate: with repositories * add: interfaces to connect a database provider - easy swapping between database provider * add: setup page - add: in-memory database provider - create a new account (signup) - login with username and password - first setup page to create an account - PASSWORD_HASH was removed from environment and files was updated/removed due to that change * update: Dockerfile * fix: review done - remove: REQUIRES_PASSWORD & RELEASE environment variables * fix: i18n translation - rename directories * update: use database * fix: typecheck * fix: review * rebase & add: persistent lowdb provider * update: french translation * revert: due to rebase * remove & documentpull/1648/head
committed by
Bernd Storath
47 changed files with 1017 additions and 267 deletions
@ -1,42 +0,0 @@ |
|||
# wg-password |
|||
|
|||
`wg-password` (wgpw) is a script that generates bcrypt password hashes for use with `wg-easy`, enhancing security by requiring passwords. |
|||
|
|||
## Features |
|||
|
|||
- Generate bcrypt password hashes. |
|||
- Easily integrate with `wg-easy` to enforce password requirements. |
|||
|
|||
## Usage with Docker |
|||
|
|||
To generate a bcrypt password hash using docker, run the following command : |
|||
|
|||
```sh |
|||
docker run --rm -it ghcr.io/wg-easy/wg-easy wgpw 'YOUR_PASSWORD' |
|||
PASSWORD_HASH='$2b$12$coPqCsPtcFO.Ab99xylBNOW4.Iu7OOA2/ZIboHN6/oyxca3MWo7fW' // literally YOUR_PASSWORD |
|||
``` |
|||
If a password is not provided, the tool will prompt you for one : |
|||
```sh |
|||
docker run --rm -it ghcr.io/wg-easy/wg-easy wgpw |
|||
Enter your password: // hidden prompt, type in your password |
|||
PASSWORD_HASH='$2b$12$coPqCsPtcFO.Ab99xylBNOW4.Iu7OOA2/ZIboHN6/oyxca3MWo7fW' |
|||
``` |
|||
|
|||
**Important** : make sure to enclose your password in **single quotes** when you run `docker run` command : |
|||
|
|||
```bash |
|||
$ echo $2b$12$coPqCsPtcF <-- not correct |
|||
b2 |
|||
$ echo "$2b$12$coPqCsPtcF" <-- not correct |
|||
b2 |
|||
$ echo '$2b$12$coPqCsPtcF' <-- correct |
|||
$2b$12$coPqCsPtcF |
|||
``` |
|||
|
|||
**Important** : Please note: don't wrap the generated hash password in single quotes when you use `docker-compose.yml`. Instead, replace each `$` symbol with two `$$` symbols. For example: |
|||
|
|||
``` yaml |
|||
- PASSWORD_HASH=$$2y$$10$$hBCoykrB95WSzuV4fafBzOHWKu9sbyVa34GJr8VV5R/pIelfEMYyG |
|||
``` |
|||
|
|||
This hash is for the password 'foobar123', obtained using the command `docker run ghcr.io/wg-easy/wg-easy wgpw 'foobar123'` and then inserted an additional `$` before each existing `$` symbol. |
@ -0,0 +1,18 @@ |
|||
export default defineI18nLocaleDetector((event, config) => { |
|||
const query = tryQueryLocale(event, { lang: '' }); |
|||
if (query) { |
|||
return query.toString(); |
|||
} |
|||
|
|||
const cookie = tryCookieLocale(event, { lang: '', name: 'i18n_locale' }); |
|||
if (cookie) { |
|||
return cookie.toString(); |
|||
} |
|||
|
|||
const header = tryHeaderLocale(event, { lang: '' }); |
|||
if (header) { |
|||
return header.toString(); |
|||
} |
|||
|
|||
return config.defaultLocale; |
|||
}); |
@ -0,0 +1,59 @@ |
|||
<template> |
|||
<main> |
|||
<div> |
|||
<h1>Welcome to your first setup of wg-easy !</h1> |
|||
<p>Please first enter an admin username and a strong password.</p> |
|||
<form @submit="newAccount"> |
|||
<div> |
|||
<label for="username">Username</label> |
|||
<input |
|||
id="username" |
|||
v-model="username" |
|||
type="text" |
|||
name="username" |
|||
autocomplete="username" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<label for="password">New Password</label> |
|||
<input |
|||
id="password" |
|||
v-model="password" |
|||
type="password" |
|||
name="password" |
|||
autocomplete="new-password" |
|||
/> |
|||
</div> |
|||
<div> |
|||
<label for="accept">I accept the condition.</label> |
|||
<input id="accept" type="checkbox" name="accept" /> |
|||
</div> |
|||
<button type="submit">Save</button> |
|||
</form> |
|||
</div> |
|||
</main> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
const username = ref<null | string>(null); |
|||
const password = ref<null | string>(null); |
|||
const authStore = useAuthStore(); |
|||
|
|||
async function newAccount(e: Event) { |
|||
e.preventDefault(); |
|||
|
|||
if (!username.value || !password.value) return; |
|||
|
|||
try { |
|||
const res = await authStore.signup(username.value, password.value); |
|||
if (res) { |
|||
navigateTo('/login'); |
|||
} |
|||
} catch (error) { |
|||
if (error instanceof Error) { |
|||
// TODO: replace alert with actual ui error message |
|||
alert(error.message || error.toString()); |
|||
} |
|||
} |
|||
} |
|||
</script> |
@ -41,6 +41,9 @@ importers: |
|||
js-sha256: |
|||
specifier: ^0.11.0 |
|||
version: 0.11.0 |
|||
lowdb: |
|||
specifier: ^7.0.1 |
|||
version: 7.0.1 |
|||
nuxt: |
|||
specifier: ^3.12.4 |
|||
version: 3.12.4(@parcel/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected])) |
|||
@ -2868,6 +2871,10 @@ packages: |
|||
[email protected]: |
|||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} |
|||
engines: {node: '>=18'} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} |
|||
|
|||
@ -3838,6 +3845,10 @@ packages: |
|||
[email protected]: |
|||
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} |
|||
engines: {node: '>=18'} |
|||
|
|||
[email protected]: |
|||
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} |
|||
|
|||
@ -7579,6 +7590,10 @@ snapshots: |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: |
|||
dependencies: |
|||
steno: 4.0.2 |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: |
|||
@ -8654,6 +8669,8 @@ snapshots: |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: {} |
|||
|
|||
[email protected]: |
|||
dependencies: |
|||
fast-fifo: 1.3.2 |
|||
|
@ -0,0 +1,24 @@ |
|||
import { DatabaseError } from '~/services/database/repositories/database'; |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
try { |
|||
const { username, password } = await readValidatedBody( |
|||
event, |
|||
validateZod(passwordType) |
|||
); |
|||
await Database.newUserWithPassword(username, password); |
|||
return { success: true }; |
|||
} catch (error) { |
|||
if (error instanceof DatabaseError) { |
|||
const t = await useTranslation(event); |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: t(error.message), |
|||
message: error.message, |
|||
}); |
|||
} else { |
|||
throw createError('Something happened !'); |
|||
} |
|||
} |
|||
}); |
@ -1,4 +1,4 @@ |
|||
export default defineEventHandler((event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
return LANG; |
|||
return Database.getLang(); |
|||
}); |
|||
|
@ -1,8 +1,14 @@ |
|||
export default defineEventHandler(async () => { |
|||
const release = Number.parseInt(RELEASE, 10); |
|||
const system = await Database.getSystem(); |
|||
if (!system) |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Invalid', |
|||
}); |
|||
|
|||
const latestRelease = await fetchLatestRelease(); |
|||
return { |
|||
currentRelease: release, |
|||
currentRelease: system.release, |
|||
latestRelease: latestRelease, |
|||
}; |
|||
}); |
|||
|
@ -1,11 +1,9 @@ |
|||
export default defineEventHandler(async (event) => { |
|||
const session = await useWGSession(event); |
|||
const authenticated = REQUIRES_PASSWORD |
|||
? session.data.authenticated === true |
|||
: true; |
|||
const authenticated = session.data.authenticated; |
|||
|
|||
return { |
|||
requiresPassword: REQUIRES_PASSWORD, |
|||
requiresPassword: true, |
|||
authenticated, |
|||
}; |
|||
}); |
|||
|
@ -1,43 +1,54 @@ |
|||
import type { SessionConfig } from 'h3'; |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const { password, remember } = await readValidatedBody( |
|||
const { username, password, remember } = await readValidatedBody( |
|||
event, |
|||
validateZod(passwordType) |
|||
validateZod(credentialsType) |
|||
); |
|||
|
|||
if (!REQUIRES_PASSWORD) { |
|||
// if no password is required, the API should never be called.
|
|||
// Do not automatically authenticate the user.
|
|||
const users = await Database.getUsers(); |
|||
const user = users.find((user) => user.username == username); |
|||
if (!user) |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Invalid state', |
|||
statusCode: 400, |
|||
statusMessage: 'Incorrect credentials', |
|||
}); |
|||
} |
|||
if (!isPasswordValid(password, PASSWORD_HASH)) { |
|||
|
|||
const userHashPassword = user.password; |
|||
if (!isPasswordValid(password, userHashPassword)) { |
|||
throw createError({ |
|||
statusCode: 401, |
|||
statusMessage: 'Incorrect Password', |
|||
statusMessage: 'Incorrect credentials', |
|||
}); |
|||
} |
|||
|
|||
const conf: SessionConfig = SESSION_CONFIG; |
|||
// TODO: timing againts timing attack
|
|||
|
|||
const system = await Database.getSystem(); |
|||
if (!system) |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Invalid', |
|||
}); |
|||
|
|||
const conf: SessionConfig = system.sessionConfig; |
|||
if (MAX_AGE && remember) { |
|||
conf.cookie = { |
|||
...(SESSION_CONFIG.cookie ?? {}), |
|||
...(system.sessionConfig.cookie ?? {}), |
|||
maxAge: MAX_AGE, |
|||
}; |
|||
} |
|||
|
|||
const session = await useSession(event, { |
|||
...SESSION_CONFIG, |
|||
...system.sessionConfig, |
|||
}); |
|||
|
|||
const data = await session.update({ |
|||
authenticated: true, |
|||
userId: user.id, |
|||
}); |
|||
|
|||
SERVER_DEBUG(`New Session: ${data.id}`); |
|||
|
|||
return { success: true, requiresPassword: REQUIRES_PASSWORD }; |
|||
return { success: true, requiresPassword: true }; |
|||
}); |
|||
|
@ -1,6 +1,11 @@ |
|||
export default defineEventHandler((event) => { |
|||
export default defineEventHandler(async (event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
// Weird issue with auto import not working. This alias is needed
|
|||
const stats = UI_TRAFFIC_STATS; |
|||
return stats === 'true' ? true : false; |
|||
const system = await Database.getSystem(); |
|||
if (!system) |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Invalid', |
|||
}); |
|||
|
|||
return system.trafficStats.enabled; |
|||
}); |
|||
|
@ -1,5 +1,11 @@ |
|||
export default defineEventHandler((event) => { |
|||
export default defineEventHandler(async (event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
const expires = WG_ENABLE_EXPIRES_TIME; |
|||
return expires === 'true' ? true : false; |
|||
const system = await Database.getSystem(); |
|||
if (!system) |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Invalid', |
|||
}); |
|||
|
|||
return system.wgEnableExpiresTime; |
|||
}); |
|||
|
@ -1,5 +1,11 @@ |
|||
export default defineEventHandler((event) => { |
|||
export default defineEventHandler(async (event) => { |
|||
setHeader(event, 'Content-Type', 'application/json'); |
|||
const otl = WG_ENABLE_ONE_TIME_LINKS; |
|||
return otl === 'true' ? true : false; |
|||
const system = await Database.getSystem(); |
|||
if (!system) |
|||
throw createError({ |
|||
statusCode: 500, |
|||
statusMessage: 'Invalid', |
|||
}); |
|||
|
|||
return system.wgEnableOneTimeLinks; |
|||
}); |
|||
|
@ -0,0 +1,16 @@ |
|||
/* First setup of wg-easy app */ |
|||
export default defineEventHandler(async (event) => { |
|||
const url = getRequestURL(event); |
|||
|
|||
if ( |
|||
url.pathname.startsWith('/setup') || |
|||
url.pathname === '/api/account/new' |
|||
) { |
|||
return; |
|||
} |
|||
|
|||
const users = await Database.getUsers(); |
|||
if (users.length === 0) { |
|||
return sendRedirect(event, '/setup', 302); |
|||
} |
|||
}); |
@ -0,0 +1,17 @@ |
|||
/** |
|||
* Changing the Database Provider |
|||
* This design allows for easy swapping of different database implementations. |
|||
* |
|||
*/ |
|||
|
|||
// import InMemory from '~/services/database/inmemory';
|
|||
import LowDb from '~/services/database/lowdb'; |
|||
|
|||
const provider = new LowDb(); |
|||
|
|||
provider.connect().catch((err) => { |
|||
console.error(err); |
|||
process.exit(1); |
|||
}); |
|||
|
|||
export default provider; |
@ -1,17 +1,47 @@ |
|||
import bcrypt from 'bcryptjs'; |
|||
|
|||
/** |
|||
* Checks if `password` matches the PASSWORD_HASH. |
|||
* Checks if `password` matches the user password. |
|||
* |
|||
* If environment variable is not set, the password is always invalid. |
|||
* @param {string} password string to test |
|||
* @returns {boolean} `true` if matching user password, otherwise `false` |
|||
*/ |
|||
export function isPasswordValid(password: string, hash: string): boolean { |
|||
return bcrypt.compareSync(password, hash); |
|||
} |
|||
|
|||
/** |
|||
* Checks if a password is strong based on following criteria : |
|||
* |
|||
* - minimum length of 12 characters |
|||
* - contains at least one uppercase letter |
|||
* - contains at least one lowercase letter |
|||
* - contains at least one number |
|||
* - contains at least one special character (e.g., !@#$%^&*(),.?":{}|<>). |
|||
* |
|||
* @param {string} password String to test |
|||
* @returns {boolean} true if matching environment, otherwise false |
|||
* @param {string} password - The password to validate |
|||
* @returns {boolean} `true` if the password is strong, otherwise `false` |
|||
*/ |
|||
export function isPasswordValid(password: string, hash?: string): boolean { |
|||
if (hash) { |
|||
return bcrypt.compareSync(password, hash); |
|||
|
|||
export function isPasswordStrong(password: string): boolean { |
|||
if (password.length < 12) { |
|||
return false; |
|||
} |
|||
|
|||
return false; |
|||
const hasUpperCase = /[A-Z]/.test(password); |
|||
const hasLowerCase = /[a-z]/.test(password); |
|||
const hasNumber = /\d/.test(password); |
|||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password); |
|||
|
|||
return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar; |
|||
} |
|||
|
|||
/** |
|||
* Hashes a password. |
|||
* |
|||
* @param {string} password - The plaintext password to hash |
|||
* @returns {string} The hash of the password |
|||
*/ |
|||
export function hashPassword(password: string): string { |
|||
return bcrypt.hashSync(password, 12); |
|||
} |
|||
|
@ -0,0 +1,93 @@ |
|||
import crypto from 'node:crypto'; |
|||
import debug from 'debug'; |
|||
|
|||
import DatabaseProvider, { DatabaseError } from './repositories/database'; |
|||
import { hashPassword, isPasswordStrong } from '~/server/utils/password'; |
|||
import { Lang } from './repositories/types'; |
|||
import SYSTEM from './repositories/system'; |
|||
|
|||
import type { User } from './repositories/user/model'; |
|||
import type { ID } from './repositories/types'; |
|||
|
|||
const DEBUG = debug('InMemoryDB'); |
|||
|
|||
// In-Memory Database Provider
|
|||
export default class InMemory extends DatabaseProvider { |
|||
async connect() { |
|||
this.data.system = SYSTEM; |
|||
DEBUG('Connection done'); |
|||
} |
|||
|
|||
async disconnect() { |
|||
this.data = { system: null, users: [] }; |
|||
DEBUG('Diconnect done'); |
|||
} |
|||
|
|||
async getSystem() { |
|||
DEBUG('Get System'); |
|||
return this.data.system; |
|||
} |
|||
|
|||
async getLang() { |
|||
return this.data.system?.lang || Lang.EN; |
|||
} |
|||
|
|||
async getUsers() { |
|||
return this.data.users; |
|||
} |
|||
|
|||
async getUser(id: ID) { |
|||
DEBUG('Get User'); |
|||
return this.data.users.find((user) => user.id === id); |
|||
} |
|||
|
|||
async newUserWithPassword(username: string, password: string) { |
|||
DEBUG('New User'); |
|||
|
|||
if (username.length < 8) { |
|||
throw new DatabaseError(DatabaseError.ERROR_USERNAME_REQ); |
|||
} |
|||
|
|||
if (!isPasswordStrong(password)) { |
|||
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ); |
|||
} |
|||
|
|||
const isUserExist = this.data.users.find( |
|||
(user) => user.username === username |
|||
); |
|||
if (isUserExist) { |
|||
throw new DatabaseError(DatabaseError.ERROR_USER_EXIST); |
|||
} |
|||
|
|||
const now = new Date(); |
|||
const isUserEmpty = this.data.users.length === 0; |
|||
|
|||
const newUser: User = { |
|||
id: crypto.randomUUID(), |
|||
password: hashPassword(password), |
|||
username, |
|||
role: isUserEmpty ? 'ADMIN' : 'CLIENT', |
|||
enabled: true, |
|||
createdAt: now, |
|||
updatedAt: now, |
|||
}; |
|||
|
|||
this.data.users.push(newUser); |
|||
} |
|||
|
|||
async updateUser(user: User) { |
|||
let _user = await this.getUser(user.id); |
|||
if (_user) { |
|||
DEBUG('Update User'); |
|||
_user = user; |
|||
} |
|||
} |
|||
|
|||
async deleteUser(id: ID) { |
|||
DEBUG('Delete User'); |
|||
const idx = this.data.users.findIndex((user) => user.id === id); |
|||
if (idx !== -1) { |
|||
this.data.users.splice(idx, 1); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,121 @@ |
|||
import crypto from 'node:crypto'; |
|||
import debug from 'debug'; |
|||
import { join } from 'path'; |
|||
|
|||
import DatabaseProvider, { DatabaseError } from './repositories/database'; |
|||
import { hashPassword, isPasswordStrong } from '~/server/utils/password'; |
|||
import { JSONFilePreset } from 'lowdb/node'; |
|||
import { Lang } from './repositories/types'; |
|||
import SYSTEM from './repositories/system'; |
|||
|
|||
import type { User } from './repositories/user/model'; |
|||
import type { DBData } from './repositories/database'; |
|||
import type { ID } from './repositories/types'; |
|||
import type { Low } from 'lowdb'; |
|||
|
|||
const DEBUG = debug('LowDB'); |
|||
|
|||
export default class LowDB extends DatabaseProvider { |
|||
private _db!: Low<DBData>; |
|||
|
|||
private async __init() { |
|||
// TODO: assume path to db file
|
|||
const dbFilePath = join(WG_PATH, 'db.json'); |
|||
this._db = await JSONFilePreset(dbFilePath, this.data); |
|||
} |
|||
|
|||
async connect() { |
|||
try { |
|||
// load file db
|
|||
await this._db.read(); |
|||
DEBUG('Connection done'); |
|||
return; |
|||
} catch (error) { |
|||
DEBUG('Database does not exist : ', error); |
|||
} |
|||
|
|||
try { |
|||
await this.__init(); |
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|||
} catch (error) { |
|||
throw new DatabaseError(DatabaseError.ERROR_INIT); |
|||
} |
|||
|
|||
this._db.update((data) => (data.system = SYSTEM)); |
|||
|
|||
DEBUG('Connection done'); |
|||
} |
|||
|
|||
async disconnect() { |
|||
DEBUG('Diconnect done'); |
|||
} |
|||
|
|||
async getSystem() { |
|||
DEBUG('Get System'); |
|||
return this._db.data.system; |
|||
} |
|||
|
|||
async getLang() { |
|||
return this._db.data.system?.lang || Lang.EN; |
|||
} |
|||
|
|||
async getUsers() { |
|||
return this._db.data.users; |
|||
} |
|||
|
|||
async getUser(id: ID) { |
|||
DEBUG('Get User'); |
|||
return this._db.data.users.find((user) => user.id === id); |
|||
} |
|||
|
|||
async newUserWithPassword(username: string, password: string) { |
|||
DEBUG('New User'); |
|||
|
|||
if (username.length < 8) { |
|||
throw new DatabaseError(DatabaseError.ERROR_USERNAME_REQ); |
|||
} |
|||
|
|||
if (!isPasswordStrong(password)) { |
|||
throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ); |
|||
} |
|||
|
|||
const isUserExist = this._db.data.users.find( |
|||
(user) => user.username === username |
|||
); |
|||
if (isUserExist) { |
|||
throw new DatabaseError(DatabaseError.ERROR_USER_EXIST); |
|||
} |
|||
|
|||
const now = new Date(); |
|||
const isUserEmpty = this._db.data.users.length === 0; |
|||
|
|||
const newUser: User = { |
|||
id: crypto.randomUUID(), |
|||
password: hashPassword(password), |
|||
username, |
|||
role: isUserEmpty ? 'ADMIN' : 'CLIENT', |
|||
enabled: true, |
|||
createdAt: now, |
|||
updatedAt: now, |
|||
}; |
|||
|
|||
this._db.update((data) => data.users.push(newUser)); |
|||
} |
|||
|
|||
async updateUser(user: User) { |
|||
let _user = await this.getUser(user.id); |
|||
if (_user) { |
|||
DEBUG('Update User'); |
|||
_user = user; |
|||
this._db.write(); |
|||
} |
|||
} |
|||
|
|||
async deleteUser(id: ID) { |
|||
DEBUG('Delete User'); |
|||
const idx = this._db.data.users.findIndex((user) => user.id === id); |
|||
if (idx !== -1) { |
|||
this._db.update((data) => data.users.splice(idx, 1)); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,89 @@ |
|||
import type SystemRepository from './system/repository'; |
|||
import type UserRepository from './user/repository.ts'; |
|||
import type { Lang, ID } from './types'; |
|||
import type { User } from './user/model'; |
|||
import type { System } from './system/model'; |
|||
|
|||
// TODO: re-export type from /user & /system
|
|||
|
|||
// Represent data structure
|
|||
export type DBData = { |
|||
system: System | null; |
|||
users: User[]; |
|||
}; |
|||
|
|||
/** |
|||
* Abstract class for database operations. |
|||
* Provides methods to connect, disconnect, and interact with system and user data. |
|||
* |
|||
* **Note :** Always throw `DatabaseError` to ensure proper API error handling. |
|||
* |
|||
*/ |
|||
export default abstract class DatabaseProvider |
|||
implements SystemRepository, UserRepository |
|||
{ |
|||
protected data: DBData = { system: null, users: [] }; |
|||
|
|||
/** |
|||
* Connects to the database. |
|||
*/ |
|||
abstract connect(): Promise<void>; |
|||
|
|||
/** |
|||
* Disconnects from the database. |
|||
*/ |
|||
abstract disconnect(): Promise<void>; |
|||
|
|||
abstract getSystem(): Promise<System | null>; |
|||
abstract getLang(): Promise<Lang>; |
|||
|
|||
abstract getUsers(): Promise<Array<User>>; |
|||
abstract getUser(id: ID): Promise<User | undefined>; |
|||
abstract newUserWithPassword( |
|||
username: string, |
|||
password: string |
|||
): Promise<void>; |
|||
abstract updateUser(_user: User): Promise<void>; |
|||
abstract deleteUser(id: ID): Promise<void>; |
|||
} |
|||
|
|||
/** |
|||
* Represents a specialized error class for database-related operations. |
|||
* This class is designed to work with internationalization (i18n) by using message keys. |
|||
* The actual error messages are expected to be retrieved using these keys from an i18n system. |
|||
* |
|||
* ### Usage: |
|||
* When throwing a `DatabaseError`, you provide an i18n key as the message. |
|||
* The key will be used by the i18n system to retrieve the corresponding localized error message. |
|||
* |
|||
* Example: |
|||
* ```typescript
|
|||
* throw new DatabaseError(DatabaseError.ERROR_PASSWORD_REQ); |
|||
* ... |
|||
* // event handler routes
|
|||
* if (error instanceof DatabaseError) { |
|||
* const t = await useTranslation(event); |
|||
* throw createError({ |
|||
* statusCode: 400, |
|||
* statusMessage: t(error.message), |
|||
* message: error.message, |
|||
* }); |
|||
* } else { |
|||
* throw createError('Something happened !'); |
|||
* } |
|||
* ``` |
|||
* |
|||
* @extends {Error} |
|||
*/ |
|||
export class DatabaseError extends Error { |
|||
static readonly ERROR_INIT = 'errorInit'; |
|||
static readonly ERROR_PASSWORD_REQ = 'errorPasswordReq'; |
|||
static readonly ERROR_USER_EXIST = 'errorUserExist'; |
|||
static readonly ERROR_DATABASE_CONNECTION = 'errorDatabaseConn'; |
|||
static readonly ERROR_USERNAME_REQ = 'errorUsernameReq'; |
|||
|
|||
constructor(message: string) { |
|||
super(message); |
|||
this.name = 'DatabaseError'; |
|||
} |
|||
} |
@ -0,0 +1,55 @@ |
|||
import packageJson from '@/package.json'; |
|||
|
|||
import { ChartType, Lang } from '../types'; |
|||
|
|||
import type { System } from './model'; |
|||
|
|||
const DEFAULT_SYSTEM_MODEL: System = { |
|||
release: packageJson.release.version, |
|||
interface: { |
|||
privateKey: '', |
|||
publicKey: '', |
|||
address: '10.8.0.1', |
|||
}, |
|||
port: PORT ? Number(PORT) : 51821, |
|||
webuiHost: '0.0.0.0', |
|||
sessionTimeout: 3600, // 1 hour
|
|||
lang: Lang.EN, |
|||
userConfig: { |
|||
mtu: 1420, |
|||
persistentKeepalive: 0, |
|||
// TODO: assume handle CIDR to compute next ip in WireGuard
|
|||
rangeAddress: '10.8.0.0/24', |
|||
defaultDns: ['1.1.1.1'], |
|||
allowedIps: ['0.0.0.0/0', '::/0'], |
|||
}, |
|||
wgPath: WG_PATH, |
|||
wgDevice: 'wg0', |
|||
wgHost: WG_HOST || '', |
|||
wgPort: 51820, |
|||
wgConfigPort: 51820, |
|||
iptables: { |
|||
wgPreUp: '', |
|||
wgPostUp: '', |
|||
wgPreDown: '', |
|||
wgPostDown: '', |
|||
}, |
|||
trafficStats: { |
|||
enabled: false, |
|||
type: ChartType.None, |
|||
}, |
|||
wgEnableExpiresTime: false, |
|||
wgEnableOneTimeLinks: false, |
|||
wgEnableSortClients: false, |
|||
prometheus: { |
|||
enabled: false, |
|||
password: null, |
|||
}, |
|||
sessionConfig: { |
|||
password: getRandomHex(256), |
|||
name: 'wg-easy', |
|||
cookie: undefined, |
|||
}, |
|||
}; |
|||
|
|||
export default DEFAULT_SYSTEM_MODEL; |
@ -0,0 +1,45 @@ |
|||
import type { SessionConfig } from 'h3'; |
|||
import type { |
|||
Url, |
|||
IpTables, |
|||
Lang, |
|||
Port, |
|||
Prometheus, |
|||
SessionTimeOut, |
|||
TrafficStats, |
|||
Version, |
|||
WGConfig, |
|||
WGInterface, |
|||
} from '../types'; |
|||
|
|||
/** |
|||
* Representing the WireGuard network configuration data structure of a computer interface system. |
|||
*/ |
|||
export type System = { |
|||
interface: WGInterface; |
|||
|
|||
release: Version; |
|||
port: number; |
|||
webuiHost: string; |
|||
// maxAge
|
|||
sessionTimeout: SessionTimeOut; |
|||
lang: Lang; |
|||
|
|||
userConfig: WGConfig; |
|||
|
|||
wgPath: string; |
|||
wgDevice: string; |
|||
wgHost: Url; |
|||
wgPort: Port; |
|||
wgConfigPort: Port; |
|||
|
|||
iptables: IpTables; |
|||
trafficStats: TrafficStats; |
|||
|
|||
wgEnableExpiresTime: boolean; |
|||
wgEnableOneTimeLinks: boolean; |
|||
wgEnableSortClients: boolean; |
|||
|
|||
prometheus: Prometheus; |
|||
sessionConfig: SessionConfig; |
|||
}; |
@ -0,0 +1,22 @@ |
|||
import type { Lang } from '../types'; |
|||
import type { System } from './model'; |
|||
|
|||
/** |
|||
* Interface for system-related database operations. |
|||
* This interface provides methods for retrieving system configuration data |
|||
* and specific system properties, such as the language setting, from the database. |
|||
*/ |
|||
export default interface SystemRepository { |
|||
/** |
|||
* Retrieves the system configuration data from the database. |
|||
* @returns {Promise<System | null>} A promise that resolves to the system data |
|||
* if found, or `undefined` if the system data is not available. |
|||
*/ |
|||
getSystem(): Promise<System | null>; |
|||
|
|||
/** |
|||
* Retrieves the system's language setting. |
|||
* @returns {Promise<Lang>} The current language setting of the system. |
|||
*/ |
|||
getLang(): Promise<Lang>; |
|||
} |
@ -0,0 +1,61 @@ |
|||
import type * as crypto from 'node:crypto'; |
|||
|
|||
export enum Lang { |
|||
/* english */ |
|||
EN = 'en', |
|||
/* french */ |
|||
FR = 'fr', |
|||
} |
|||
|
|||
export type Ipv4 = `${number}.${number}.${number}.${number}`; |
|||
export type Ipv4CIDR = `${number}.${number}.${number}.${number}/${number}`; |
|||
export type Ipv6 = |
|||
`${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}`; |
|||
export type Ipv6CIDR = |
|||
`${string}:${string}:${string}:${string}:${string}:${string}:${string}:${string}/${number}`; |
|||
|
|||
export type Address = Ipv4 | Ipv4CIDR | Ipv6 | Ipv6CIDR | '::/0'; |
|||
|
|||
export type UrlHttp = `http://${string}`; |
|||
export type UrlHttps = `https://${string}`; |
|||
export type Url = string | UrlHttp | UrlHttps | Address; |
|||
|
|||
export type ID = crypto.UUID; |
|||
export type Version = string; |
|||
export type SessionTimeOut = number; |
|||
export type Port = number; |
|||
export type HashPassword = string; |
|||
export type Command = string; |
|||
export type Key = string; |
|||
export type IpTables = { |
|||
wgPreUp: Command; |
|||
wgPostUp: Command; |
|||
wgPreDown: Command; |
|||
wgPostDown: Command; |
|||
}; |
|||
export type WGInterface = { |
|||
privateKey: Key; |
|||
publicKey: Key; |
|||
address: Address; |
|||
}; |
|||
export type WGConfig = { |
|||
mtu: number; |
|||
persistentKeepalive: number; |
|||
rangeAddress: Address; |
|||
defaultDns: Array<Address>; |
|||
allowedIps: Array<Address>; |
|||
}; |
|||
export enum ChartType { |
|||
None = 0, |
|||
Line = 1, |
|||
Area = 2, |
|||
Bar = 3, |
|||
} |
|||
export type TrafficStats = { |
|||
enabled: boolean; |
|||
type: ChartType; |
|||
}; |
|||
export type Prometheus = { |
|||
enabled: boolean; |
|||
password: HashPassword | null; |
|||
}; |
@ -0,0 +1,29 @@ |
|||
import type { Address, ID, Key, HashPassword } from '../types'; |
|||
|
|||
/** |
|||
* Represents user roles within the application, each with specific permissions : |
|||
* |
|||
* - `ADMIN`: Full permissions to all resources, including the app, database, etc |
|||
* - `EDITOR`: Granted write and read permissions on their own resources as well as |
|||
* `CLIENT` resources, but without `ADMIN` privileges |
|||
* - `CLIENT`: Granted write and read permissions only on their own resources. |
|||
*/ |
|||
export type ROLE = 'ADMIN' | 'EDITOR' | 'CLIENT'; |
|||
|
|||
/** |
|||
* Representing a user data structure. |
|||
*/ |
|||
export type User = { |
|||
id: ID; |
|||
role: ROLE; |
|||
username: string; |
|||
password: HashPassword; |
|||
name?: string; |
|||
address?: Address; |
|||
privateKey?: Key; |
|||
publicKey?: Key; |
|||
preSharedKey?: string; |
|||
createdAt: Date; |
|||
updatedAt: Date; |
|||
enabled: boolean; |
|||
}; |
@ -0,0 +1,39 @@ |
|||
import type { ID } from '../types'; |
|||
import type { User } from './model'; |
|||
|
|||
/** |
|||
* Interface for user-related database operations. |
|||
* This interface provides methods for managing user data. |
|||
*/ |
|||
export default interface UserRepository { |
|||
/** |
|||
* Retrieves all users from the database. |
|||
* @returns {Promise<Array<User>>} A array of users data. |
|||
*/ |
|||
getUsers(): Promise<Array<User>>; |
|||
|
|||
/** |
|||
* Retrieves a user by their ID or User object from the database. |
|||
* @param {ID} id - The ID of the user or a User object. |
|||
* @returns {Promise<User | undefined>} A promise that resolves to the user data |
|||
* if found, or `undefined` if the user is not available. |
|||
*/ |
|||
getUser(id: ID): Promise<User | undefined>; |
|||
|
|||
newUserWithPassword(username: string, password: string): Promise<void>; |
|||
|
|||
/** |
|||
* Updates a user in the database. |
|||
* @param {User} user - The user to be saved. |
|||
* |
|||
* @returns {Promise<void>} A promise that resolves when the operation is complete. |
|||
*/ |
|||
updateUser(user: User): Promise<void>; |
|||
|
|||
/** |
|||
* Deletes a user from the database. |
|||
* @param {ID} id - The ID of the user or a User object to be deleted. |
|||
* @returns {Promise<void>} A promise that resolves when the user has been deleted. |
|||
*/ |
|||
deleteUser(id: ID): Promise<void>; |
|||
} |
@ -1,77 +0,0 @@ |
|||
// Import needed libraries
|
|||
import bcrypt from 'bcryptjs'; |
|||
import { Writable } from 'stream'; |
|||
import readline from 'readline'; |
|||
|
|||
// Function to generate hash
|
|||
const generateHash = async (password) => { |
|||
try { |
|||
const salt = await bcrypt.genSalt(12); |
|||
const hash = await bcrypt.hash(password, salt); |
|||
|
|||
console.log(`PASSWORD_HASH='${hash}'`); |
|||
} catch (error) { |
|||
throw new Error(`Failed to generate hash : ${error}`); |
|||
} |
|||
}; |
|||
|
|||
// Function to compare password with hash
|
|||
const comparePassword = async (password, hash) => { |
|||
try { |
|||
const match = await bcrypt.compare(password, hash); |
|||
if (match) { |
|||
console.log('Password matches the hash !'); |
|||
} else { |
|||
console.log('Password does not match the hash.'); |
|||
} |
|||
} catch (error) { |
|||
throw new Error(`Failed to compare password and hash : ${error}`); |
|||
} |
|||
}; |
|||
|
|||
const readStdinPassword = () => { |
|||
return new Promise((resolve) => { |
|||
process.stdout.write('Enter your password: '); |
|||
|
|||
const rl = readline.createInterface({ |
|||
input: process.stdin, |
|||
output: new Writable({ |
|||
write(_chunk, _encoding, callback) { |
|||
callback(); |
|||
}, |
|||
}), |
|||
terminal: true, |
|||
}); |
|||
|
|||
rl.question('', (answer) => { |
|||
rl.close(); |
|||
// Print a new line after password prompt
|
|||
process.stdout.write('\n'); |
|||
resolve(answer); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
(async () => { |
|||
try { |
|||
// Retrieve command line arguments
|
|||
const args = process.argv.slice(2); // Ignore the first two arguments
|
|||
if (args.length > 2) { |
|||
throw new Error('Usage : wgpw [YOUR_PASSWORD] [HASH]'); |
|||
} |
|||
|
|||
const [password, hash] = args; |
|||
if (password && hash) { |
|||
await comparePassword(password, hash); |
|||
} else if (password) { |
|||
await generateHash(password); |
|||
} else { |
|||
const password = await readStdinPassword(); |
|||
await generateHash(password); |
|||
} |
|||
} catch (error) { |
|||
console.error(error); |
|||
|
|||
process.exit(1); |
|||
} |
|||
})(); |
@ -1,5 +0,0 @@ |
|||
#!/bin/sh |
|||
# This script is intended to be run only inside a docker container, not on the development host machine |
|||
set -e |
|||
# proxy command |
|||
node /app/wgpw.mjs "$@" |
Loading…
Reference in new issue