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: |
js-sha256: |
||||
specifier: ^0.11.0 |
specifier: ^0.11.0 |
||||
version: 0.11.0 |
version: 0.11.0 |
||||
|
lowdb: |
||||
|
specifier: ^7.0.1 |
||||
|
version: 7.0.1 |
||||
nuxt: |
nuxt: |
||||
specifier: ^3.12.4 |
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])) |
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]: |
[email protected]: |
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} |
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} |
||||
|
|
||||
|
[email protected]: |
||||
|
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} |
||||
|
engines: {node: '>=18'} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} |
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} |
||||
|
|
||||
@ -3838,6 +3845,10 @@ packages: |
|||||
[email protected]: |
[email protected]: |
||||
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} |
resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} |
||||
|
|
||||
|
[email protected]: |
||||
|
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} |
||||
|
engines: {node: '>=18'} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} |
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} |
||||
|
|
||||
@ -7579,6 +7590,10 @@ snapshots: |
|||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
|
[email protected]: |
||||
|
dependencies: |
||||
|
steno: 4.0.2 |
||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
@ -8654,6 +8669,8 @@ snapshots: |
|||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
|
[email protected]: {} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
dependencies: |
dependencies: |
||||
fast-fifo: 1.3.2 |
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) => { |
export default defineEventHandler((event) => { |
||||
setHeader(event, 'Content-Type', 'application/json'); |
setHeader(event, 'Content-Type', 'application/json'); |
||||
return LANG; |
return Database.getLang(); |
||||
}); |
}); |
||||
|
@ -1,8 +1,14 @@ |
|||||
export default defineEventHandler(async () => { |
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(); |
const latestRelease = await fetchLatestRelease(); |
||||
return { |
return { |
||||
currentRelease: release, |
currentRelease: system.release, |
||||
latestRelease: latestRelease, |
latestRelease: latestRelease, |
||||
}; |
}; |
||||
}); |
}); |
||||
|
@ -1,11 +1,9 @@ |
|||||
export default defineEventHandler(async (event) => { |
export default defineEventHandler(async (event) => { |
||||
const session = await useWGSession(event); |
const session = await useWGSession(event); |
||||
const authenticated = REQUIRES_PASSWORD |
const authenticated = session.data.authenticated; |
||||
? session.data.authenticated === true |
|
||||
: true; |
|
||||
|
|
||||
return { |
return { |
||||
requiresPassword: REQUIRES_PASSWORD, |
requiresPassword: true, |
||||
authenticated, |
authenticated, |
||||
}; |
}; |
||||
}); |
}); |
||||
|
@ -1,43 +1,54 @@ |
|||||
import type { SessionConfig } from 'h3'; |
import type { SessionConfig } from 'h3'; |
||||
|
|
||||
export default defineEventHandler(async (event) => { |
export default defineEventHandler(async (event) => { |
||||
const { password, remember } = await readValidatedBody( |
const { username, password, remember } = await readValidatedBody( |
||||
event, |
event, |
||||
validateZod(passwordType) |
validateZod(credentialsType) |
||||
); |
); |
||||
|
|
||||
if (!REQUIRES_PASSWORD) { |
const users = await Database.getUsers(); |
||||
// if no password is required, the API should never be called.
|
const user = users.find((user) => user.username == username); |
||||
// Do not automatically authenticate the user.
|
if (!user) |
||||
throw createError({ |
throw createError({ |
||||
statusCode: 401, |
statusCode: 400, |
||||
statusMessage: 'Invalid state', |
statusMessage: 'Incorrect credentials', |
||||
}); |
}); |
||||
} |
|
||||
if (!isPasswordValid(password, PASSWORD_HASH)) { |
const userHashPassword = user.password; |
||||
|
if (!isPasswordValid(password, userHashPassword)) { |
||||
throw createError({ |
throw createError({ |
||||
statusCode: 401, |
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) { |
if (MAX_AGE && remember) { |
||||
conf.cookie = { |
conf.cookie = { |
||||
...(SESSION_CONFIG.cookie ?? {}), |
...(system.sessionConfig.cookie ?? {}), |
||||
maxAge: MAX_AGE, |
maxAge: MAX_AGE, |
||||
}; |
}; |
||||
} |
} |
||||
|
|
||||
const session = await useSession(event, { |
const session = await useSession(event, { |
||||
...SESSION_CONFIG, |
...system.sessionConfig, |
||||
}); |
}); |
||||
|
|
||||
const data = await session.update({ |
const data = await session.update({ |
||||
authenticated: true, |
authenticated: true, |
||||
|
userId: user.id, |
||||
}); |
}); |
||||
|
|
||||
SERVER_DEBUG(`New Session: ${data.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'); |
setHeader(event, 'Content-Type', 'application/json'); |
||||
// Weird issue with auto import not working. This alias is needed
|
const system = await Database.getSystem(); |
||||
const stats = UI_TRAFFIC_STATS; |
if (!system) |
||||
return stats === 'true' ? true : false; |
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'); |
setHeader(event, 'Content-Type', 'application/json'); |
||||
const expires = WG_ENABLE_EXPIRES_TIME; |
const system = await Database.getSystem(); |
||||
return expires === 'true' ? true : false; |
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'); |
setHeader(event, 'Content-Type', 'application/json'); |
||||
const otl = WG_ENABLE_ONE_TIME_LINKS; |
const system = await Database.getSystem(); |
||||
return otl === 'true' ? true : false; |
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'; |
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 |
* @param {string} password - The password to validate |
||||
* @returns {boolean} true if matching environment, otherwise false |
* @returns {boolean} `true` if the password is strong, otherwise `false` |
||||
*/ |
*/ |
||||
export function isPasswordValid(password: string, hash?: string): boolean { |
|
||||
if (hash) { |
export function isPasswordStrong(password: string): boolean { |
||||
return bcrypt.compareSync(password, hash); |
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